git.iko.soy/opencode-android
on 0e7d36c help sign in
251 files changed+47616−8015
since splitsnapshots
unifiedsplit
.github/workflows/build.yml+28−12 .github/workflows/release.yml+36−53 .gitignore−19 AGENTS.md+197 CLAUDE.md+1−40 README.md+64−126 app/build.gradle.kts+116−46 app/detekt-baseline.xml+33 app/proguard-rules.pro+48−13 app/src/androidTest/java/soy/iko/opencode/ApplicationBootstrapTest.kt+36 app/src/androidTest/java/soy/iko/opencode/data/repo/CrashLoggerTest.kt+67 app/src/androidTest/java/soy/iko/opencode/data/repo/DraftStoreTest.kt+59 app/src/main/AndroidManifest.xml+115−6 app/src/main/java/soy/iko/opencode/CoreViewModel.kt−66 app/src/main/java/soy/iko/opencode/MainActivity.kt+473−15 app/src/main/java/soy/iko/opencode/OpencodeApp.kt+130 app/src/main/java/soy/iko/opencode/core/HttpClient.kt−75 app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt−668 app/src/main/java/soy/iko/opencode/core/Protocol.kt−148 app/src/main/java/soy/iko/opencode/core/SseClient.kt−63 app/src/main/java/soy/iko/opencode/core/ViewModel.kt−77 app/src/main/java/soy/iko/opencode/data/model/Agent.kt+37 app/src/main/java/soy/iko/opencode/data/model/BusEvent.kt+109 app/src/main/java/soy/iko/opencode/data/model/Common.kt+41 app/src/main/java/soy/iko/opencode/data/model/Files.kt+44 app/src/main/java/soy/iko/opencode/data/model/Message.kt+69 app/src/main/java/soy/iko/opencode/data/model/Part.kt+127 app/src/main/java/soy/iko/opencode/data/model/Permission.kt+58 app/src/main/java/soy/iko/opencode/data/model/Project.kt+32 app/src/main/java/soy/iko/opencode/data/model/Prompt.kt+68 app/src/main/java/soy/iko/opencode/data/model/Provider.kt+84 app/src/main/java/soy/iko/opencode/data/model/Search.kt+101 app/src/main/java/soy/iko/opencode/data/model/ServerProfile.kt+27 app/src/main/java/soy/iko/opencode/data/model/Session.kt+68 app/src/main/java/soy/iko/opencode/data/model/Todo.kt+125 app/src/main/java/soy/iko/opencode/data/model/ToolState.kt+62 app/src/main/java/soy/iko/opencode/data/network/CertPinUtil.kt+35 app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+360 app/src/main/java/soy/iko/opencode/data/network/HttpClientFactory.kt+178 app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+449 app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+575 app/src/main/java/soy/iko/opencode/data/network/OpencodeJson.kt+37 app/src/main/java/soy/iko/opencode/data/repo/AttachmentDraftStore.kt+117 app/src/main/java/soy/iko/opencode/data/repo/BackupManager.kt+214 app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.kt+323 app/src/main/java/soy/iko/opencode/data/repo/DraftStore.kt+295 app/src/main/java/soy/iko/opencode/data/repo/ErrorKind.kt+81 app/src/main/java/soy/iko/opencode/data/repo/FileBrowserPrefs.kt+90 app/src/main/java/soy/iko/opencode/data/repo/MessageCacheStore.kt+151 app/src/main/java/soy/iko/opencode/data/repo/OutboxStore.kt+171 app/src/main/java/soy/iko/opencode/data/repo/ProfileStore.kt+285 app/src/main/java/soy/iko/opencode/data/repo/RecentModelsStore.kt+80 app/src/main/java/soy/iko/opencode/data/repo/RecentSessionsStore.kt+100 app/src/main/java/soy/iko/opencode/data/repo/SearchHistoryStore.kt+68 app/src/main/java/soy/iko/opencode/data/repo/SessionPrefsStore.kt+73 app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+802 app/src/main/java/soy/iko/opencode/data/repo/SettingsStore.kt+279 app/src/main/java/soy/iko/opencode/di/AppContainer.kt+1570 app/src/main/java/soy/iko/opencode/di/OpencodeConnection.kt+52 app/src/main/java/soy/iko/opencode/notification/NotificationActionReceiver.kt+195 app/src/main/java/soy/iko/opencode/notification/NotificationChannels.kt+65 app/src/main/java/soy/iko/opencode/notification/RunForegroundService.kt+375 app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt+564 app/src/main/java/soy/iko/opencode/platform/AppShortcuts.kt+65 app/src/main/java/soy/iko/opencode/platform/NewSessionTileService.kt+33 app/src/main/java/soy/iko/opencode/platform/SessionsWidgetProvider.kt+83 app/src/main/java/soy/iko/opencode/platform/SessionsWidgetService.kt+72 app/src/main/java/soy/iko/opencode/ui/AppLockGate.kt+312 app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+607−90 app/src/main/java/soy/iko/opencode/ui/Routes.kt+36 app/src/main/java/soy/iko/opencode/ui/VmFactory.kt+16 app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.kt+318 app/src/main/java/soy/iko/opencode/ui/chat/AttachmentResult.kt+12 app/src/main/java/soy/iko/opencode/ui/chat/Attachments.kt+117 app/src/main/java/soy/iko/opencode/ui/chat/ChatAttachments.kt+258 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+3432 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+2025 app/src/main/java/soy/iko/opencode/ui/chat/CommandPickerSheet.kt+248 app/src/main/java/soy/iko/opencode/ui/chat/ConversationExport.kt+236 app/src/main/java/soy/iko/opencode/ui/chat/CostFormat.kt+60 app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+1418 app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.kt+330 app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+811 app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.kt+331 app/src/main/java/soy/iko/opencode/ui/chat/TodoPlan.kt+205 app/src/main/java/soy/iko/opencode/ui/chat/TtsController.kt+278 app/src/main/java/soy/iko/opencode/ui/chat/ViewSourceDialog.kt+76 app/src/main/java/soy/iko/opencode/ui/components/AppTopBar.kt+82 app/src/main/java/soy/iko/opencode/ui/components/Autofill.kt+49 app/src/main/java/soy/iko/opencode/ui/components/ChatTextPrefs.kt+54 app/src/main/java/soy/iko/opencode/ui/components/CodeHighlighter.kt+632 app/src/main/java/soy/iko/opencode/ui/components/CommandPalette.kt+171 app/src/main/java/soy/iko/opencode/ui/components/CommonStates.kt+170 app/src/main/java/soy/iko/opencode/ui/components/CompactNavBar.kt+106 app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.kt+266 app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+662 app/src/main/java/soy/iko/opencode/ui/components/EmptyState.kt+77 app/src/main/java/soy/iko/opencode/ui/components/GatedHapticFeedback.kt+33 app/src/main/java/soy/iko/opencode/ui/components/LargeScreenNavRail.kt+109 app/src/main/java/soy/iko/opencode/ui/components/LoadingSize.kt+12 app/src/main/java/soy/iko/opencode/ui/components/LoadingSpinner.kt+40 app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+902 app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt−479 app/src/main/java/soy/iko/opencode/ui/components/PaletteAction.kt+11 app/src/main/java/soy/iko/opencode/ui/components/ReducedMotion.kt+131 app/src/main/java/soy/iko/opencode/ui/components/RelativeTimeUnit.kt+56 app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+606 app/src/main/java/soy/iko/opencode/ui/components/SectionHeader.kt+25 app/src/main/java/soy/iko/opencode/ui/components/SkeletonRow.kt+97 app/src/main/java/soy/iko/opencode/ui/components/TimeFormat.kt+194 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+1043 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt+281 app/src/main/java/soy/iko/opencode/ui/file/FileViewModel.kt+91 app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+1323 app/src/main/java/soy/iko/opencode/ui/file/TwoPaneFileBrowser.kt+117 app/src/main/java/soy/iko/opencode/ui/mcp/McpScreen.kt+521 app/src/main/java/soy/iko/opencode/ui/mcp/McpServerInfo.kt+76 app/src/main/java/soy/iko/opencode/ui/mcp/McpViewModel.kt+173 app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt−590 app/src/main/java/soy/iko/opencode/ui/screens/ConnectScreen.kt−222 app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt−402 app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchScreen.kt+430 app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchViewModel.kt+369 app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+353 app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+355 app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+710 app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+256 app/src/main/java/soy/iko/opencode/ui/server/ServerSettingsScreen.kt+428 app/src/main/java/soy/iko/opencode/ui/server/ServerSettingsViewModel.kt+226 app/src/main/java/soy/iko/opencode/ui/session/NewSessionDialog.kt+315 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+2064 app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+1124 app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.kt+292 app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+689 app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+1660 app/src/main/java/soy/iko/opencode/ui/theme/Color.kt−19 app/src/main/java/soy/iko/opencode/ui/theme/Shapes.kt+19 app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt+179−38 app/src/main/java/soy/iko/opencode/ui/theme/Type.kt−15 app/src/main/java/soy/iko/opencode/ui/theme/Typography.kt+136 app/src/main/java/soy/iko/opencode/ui/usage/UsageAggregation.kt+152 app/src/main/java/soy/iko/opencode/ui/usage/UsageScreen.kt+435 app/src/main/java/soy/iko/opencode/ui/usage/UsageViewModel.kt+161 app/src/main/java/soy/iko/opencode/util/Contexts.kt+15 app/src/main/java/soy/iko/opencode/util/Coroutines.kt+56 app/src/main/res/drawable/ic_action_block.xml+10 app/src/main/res/drawable/ic_action_check.xml+9 app/src/main/res/drawable/ic_action_refresh.xml+9 app/src/main/res/drawable/ic_action_reply.xml+9 app/src/main/res/drawable/ic_action_stop.xml+9 app/src/main/res/drawable/ic_launcher_foreground.xml+13 app/src/main/res/drawable/ic_stat_notify.xml+9 app/src/main/res/drawable/widget_background.xml+8 app/src/main/res/layout/widget_session_item.xml+21 app/src/main/res/layout/widget_sessions.xml+64 app/src/main/res/mipmap-anydpi/ic_launcher.xml+6 app/src/main/res/values-es/strings.xml+902 app/src/main/res/values-es/strings_cross_ux.xml+7 app/src/main/res/values-es/strings_diag_ux.xml+4 app/src/main/res/values-es/strings_notif_ux.xml+5 app/src/main/res/values-es/strings_settings_ux.xml+12 app/src/main/res/values-night/colors.xml+15−1 app/src/main/res/values-night/themes.xml+2−2 app/src/main/res/values/colors.xml+19−1 app/src/main/res/values/strings.xml+925 app/src/main/res/values/strings_cross_ux.xml+7 app/src/main/res/values/strings_diag_ux.xml+4 app/src/main/res/values/strings_notif_ux.xml+8 app/src/main/res/values/strings_settings_ux.xml+12 app/src/main/res/values/themes.xml+16−7 app/src/main/res/xml/data_extraction_rules.xml+15 app/src/main/res/xml/file_paths.xml+12 app/src/main/res/xml/network_security_config.xml+15 app/src/main/res/xml/sessions_widget_info.xml+10 app/src/test/java/soy/iko/opencode/M5DecodingTest.kt+144 app/src/test/java/soy/iko/opencode/M6Test.kt+102 app/src/test/java/soy/iko/opencode/OpencodeJsonTest.kt+122 app/src/test/java/soy/iko/opencode/UxTest.kt+104 app/src/test/java/soy/iko/opencode/core/ConnectUrlTest.kt−69 app/src/test/java/soy/iko/opencode/data/model/TodoPlanTest.kt+90 app/src/test/java/soy/iko/opencode/data/network/EventStreamClientTest.kt+37 app/src/test/java/soy/iko/opencode/data/network/NormalizeBaseUrlTest.kt+42 app/src/test/java/soy/iko/opencode/data/network/OpencodeApiClientTest.kt+115 app/src/test/java/soy/iko/opencode/data/network/WithRetryTest.kt+254 app/src/test/java/soy/iko/opencode/data/repo/CrashLoggerScrubTest.kt+46 app/src/test/java/soy/iko/opencode/data/repo/ErrorKindTest.kt+60 app/src/test/java/soy/iko/opencode/data/repo/MessageStoreTest.kt+385 app/src/test/java/soy/iko/opencode/data/repo/OutboxStoreTest.kt+44 app/src/test/java/soy/iko/opencode/data/repo/SessionRepositoryTest.kt+73 app/src/test/java/soy/iko/opencode/di/UnreadTrackingTest.kt+56 app/src/test/java/soy/iko/opencode/ui/chat/ChatViewModelTest.kt+650 app/src/test/java/soy/iko/opencode/ui/chat/ConversationExportTest.kt+120 app/src/test/java/soy/iko/opencode/ui/chat/ToolDurationFormatTest.kt+45 app/src/test/java/soy/iko/opencode/ui/components/CodeHighlighterLanguageTest.kt+66 app/src/test/java/soy/iko/opencode/ui/components/DiffParseTest.kt+55 app/src/test/java/soy/iko/opencode/ui/components/ExtractCodeTextTest.kt+56 app/src/test/java/soy/iko/opencode/ui/components/ExtractFenceLanguageTest.kt+28 app/src/test/java/soy/iko/opencode/ui/file/FileBrowserViewModelTest.kt+231 app/src/test/java/soy/iko/opencode/ui/file/FileViewModelTest.kt+70 app/src/test/java/soy/iko/opencode/ui/mcp/McpParserTest.kt+52 app/src/test/java/soy/iko/opencode/ui/search/GlobalSearchViewModelTest.kt+120 app/src/test/java/soy/iko/opencode/ui/server/CertPinValidationTest.kt+39 app/src/test/java/soy/iko/opencode/ui/server/ServerEditViewModelTest.kt+425 app/src/test/java/soy/iko/opencode/ui/server/ServerSettingsViewModelTest.kt+287 app/src/test/java/soy/iko/opencode/ui/session/SessionListViewModelTest.kt+649 app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.kt+486 app/src/test/java/soy/iko/opencode/ui/usage/UsageAggregationTest.kt+67 build.gradle.kts+1−2 config/detekt/detekt.yml+68 flake.lock+3−3 flake.nix+19−29 generated/com/novi/bincode/BincodeDeserializer.java−38 generated/com/novi/bincode/BincodeSerializer.java−33 generated/com/novi/serde/ArrayLen.java−12 generated/com/novi/serde/BinaryDeserializer.java−195 generated/com/novi/serde/BinarySerializer.java−146 generated/com/novi/serde/Bytes.java−54 generated/com/novi/serde/DeserializationError.java−11 generated/com/novi/serde/Deserializer.java−56 generated/com/novi/serde/Int128.java−11 generated/com/novi/serde/SerializationError.java−11 generated/com/novi/serde/Serializer.java−58 generated/com/novi/serde/Slice.java−40 generated/com/novi/serde/Tuple2.java−40 generated/com/novi/serde/Tuple3.java−46 generated/com/novi/serde/Tuple4.java−54 generated/com/novi/serde/Tuple5.java−61 generated/com/novi/serde/Tuple6.java−67 generated/com/novi/serde/Unit.java−20 generated/com/novi/serde/Unsigned.java−11 generated/soy/iko/opencode/shared/MessageStatus.java−150 generated/soy/iko/opencode/shared/MessageView.java−131 generated/soy/iko/opencode/shared/Requests.java−27 generated/soy/iko/opencode/shared/Screen.java−150 generated/soy/iko/opencode/shared/SessionView.java−77 generated/soy/iko/opencode/shared/ToolView.java−86 generated/soy/iko/opencode/shared/TraitHelpers.java−71 generated/soy/iko/opencode/shared/ViewModel.java−185 gradle.properties+4−5 gradle/libs.versions.toml+58−29 gradle/wrapper/gradle-wrapper.properties+1−1 renovate.json+32 scripts/run-device.sh+106 scripts/run.sh+95 settings.gradle.kts−3 shared/Cargo.lock−1358 shared/Cargo.toml−31 shared/src/app.rs−1051 shared/src/bin/codegen.rs−35 shared/src/lib.rs−17 shared/src/protocol.rs−248
.github/workflows/build.yml+28−12
@@ -12,19 +12,7 @@ concurrency:
1212 cancel-in-progress: true
1313
1414jobs:
15− # The Crux core is plain Rust — build and unit-test it natively (the Android
16− # shell mirrors this logic; see README).
17− core:
18− name: Crux core
19− runs-on: ubuntu-latest
20− steps:
21− - uses: actions/checkout@v4
22− - name: Test the Crux core
23− working-directory: shared
24− run: cargo test
25−
2615 build:
27− name: Android app
2816 runs-on: ubuntu-latest
2917
3018 steps:
@@ -42,15 +30,43 @@ jobs:
4230 - name: Run unit tests
4331 run: ./gradlew testDebugUnitTest --no-daemon
4432
33+ - name: Static analysis (detekt)
34+ run: ./gradlew detekt --no-daemon
35+
4536 - name: Lint
4637 run: ./gradlew lintDebug --no-daemon
4738
4839 - name: Build debug APK
4940 run: ./gradlew assembleDebug --no-daemon
5041
42+ - name: Decode release keystore
43+ run: |
44+ mkdir -p keystore
45+ printf '%s' "$OPENCODE_STORE_BASE64" | base64 -d > keystore/opencode-release.jks
46+ env:
47+ OPENCODE_STORE_BASE64: ${{ secrets.OPENCODE_STORE_BASE64 }}
48+
49+ - name: Build release APK
50+ env:
51+ OPENCODE_STORE_FILE: ${{ github.workspace }}/keystore/opencode-release.jks
52+ OPENCODE_STORE_PASSWORD: ${{ secrets.OPENCODE_STORE_PASSWORD }}
53+ OPENCODE_KEY_ALIAS: ${{ secrets.OPENCODE_KEY_ALIAS }}
54+ OPENCODE_KEY_PASSWORD: ${{ secrets.OPENCODE_KEY_PASSWORD }}
55+ run: ./gradlew assembleRelease --no-daemon
56+
5157 - name: Upload debug APK
5258 uses: actions/upload-artifact@v4
5359 with:
5460 name: opencode-debug.apk
5561 path: app/build/outputs/apk/debug/*.apk
5662 if-no-files-found: error
63+
64+ # The release output dir is `release/` when unsigned but `release-signed/` (or a
65+ # variant name) once a signing config is applied. Match both so CI keeps working
66+ # once the OPENCODE_STORE_* secrets are wired up.
67+ - name: Upload release APK
68+ uses: actions/upload-artifact@v4
69+ with:
70+ name: opencode-release.apk
71+ path: app/build/outputs/apk/**/*.apk
72+ if-no-files-found: error
.github/workflows/release.yml+36−53
@@ -1,28 +1,17 @@
11name: Release
22
3−# Build a minified, signed release APK and publish it as a GitHub Release.
4−#
5−# Runs automatically on every push to `master` (e.g. a merged PR), on a pushed
6−# version tag (e.g. `git tag v1.2.0 && git push --tags`), or manually from the
7−# Actions tab (workflow_dispatch). For a branch push or manual run the version
8−# is derived from the current UTC time as YYYY.MM.DD.HHMM; a tag push uses the
9−# tag name (with the leading `v` stripped) as the version.
10−#
11−# The `v*` tag this workflow pushes when publishing a release does NOT retrigger
12−# it: events created with the default GITHUB_TOKEN don't start new workflow runs.
13−#
14−# Signing: if the `OPENCODE_STORE_*` secrets are configured the APK is signed
15−# with that keystore; otherwise the Gradle build falls back to the debug key so
16−# the released APK is still installable (see app/build.gradle.kts). The version
17−# is passed to Gradle as `-PversionName/-PversionCode` and stamped into the APK.
3+# Build the production (release) APK and publish a GitHub Release for every
4+# commit that lands on master. The version -- and the git tag / release name --
5+# is derived from the commit's committer date in UTC, formatted as
6+# YYYY.MM.DD.HHMM (e.g. 2026.07.04.1430). It is passed to the Gradle build as
7+# `-PversionName=...` so it is stamped into the APK and surfaces in-app.
188on:
199 push:
2010 branches: [master]
21− tags: ["v*"]
22− workflow_dispatch:
2311
24−# Never abort a release mid-flight — a half-published release with a missing
25−# artifact is worse than a slightly delayed one.
12+# A release run should never be aborted mid-flight by a newer push (a
13+# half-published release with a missing artifact is worse than a slightly stale
14+# one), so the group is per-ref but cancel-in-progress is left off.
2615concurrency:
2716 group: ${{ github.workflow }}-${{ github.ref }}
2817 cancel-in-progress: false
@@ -31,14 +20,9 @@ jobs:
3120 release:
3221 runs-on: ubuntu-latest
3322 permissions:
34− contents: write # push the tag and create the release
35− id-token: write # OIDC so GitHub can sign the provenance attestation
36− attestations: write # publish the attestation record
37− env:
38− OPENCODE_STORE_BASE64: ${{ secrets.OPENCODE_STORE_BASE64 }}
39− OPENCODE_STORE_PASSWORD: ${{ secrets.OPENCODE_STORE_PASSWORD }}
40− OPENCODE_KEY_ALIAS: ${{ secrets.OPENCODE_KEY_ALIAS }}
41− OPENCODE_KEY_PASSWORD: ${{ secrets.OPENCODE_KEY_PASSWORD }}
23+ contents: write # required to push the tag and create the release
24+ id-token: write # required for OIDC so GitHub can sign the attestation
25+ attestations: write # required to publish the attestation record
4226 steps:
4327 - uses: actions/checkout@v4
4428 with:
@@ -46,23 +30,12 @@ jobs:
4630 # accurate instead of collapsing to 1 on a shallow clone.
4731 fetch-depth: 0
4832
49− - name: Compute version
33+ - name: Compute version from commit date
5034 id: version
5135 run: |
52− if [ "${{ github.ref_type }}" = "tag" ]; then
53− VERSION="${GITHUB_REF_NAME#v}"
54− TAG="${GITHUB_REF_NAME}"
55− else
56− VERSION="$(TZ=UTC date +%Y.%m.%d.%H%M)"
57− TAG="v${VERSION}"
58− fi
59− CODE="$(git rev-list --count HEAD)"
60− {
61− echo "version=$VERSION"
62− echo "tag=$TAG"
63− echo "code=$CODE"
64− } >> "$GITHUB_OUTPUT"
65− echo "Releasing $TAG (version $VERSION, code $CODE)"
36+ VERSION="$(TZ=UTC git log -1 --format=%cd --date=format-local:%Y.%m.%d.%H%M)"
37+ echo "version=$VERSION" >> "$GITHUB_OUTPUT"
38+ echo "Version: $VERSION"
6639
6740 - uses: actions/setup-java@v4
6841 with:
@@ -74,30 +47,40 @@ jobs:
7447 - uses: gradle/actions/setup-gradle@v4
7548
7649 # The keystore ships as base64 in a secret so it never touches the repo.
77− # Job-level env makes OPENCODE_STORE_BASE64 available to this `if`.
50+ # When the secret isn't configured the Gradle build falls back to an
51+ # unsigned APK (the same behavior as a local build), so this workflow is
52+ # green even before signing is wired up.
7853 - name: Decode signing keystore
7954 if: env.OPENCODE_STORE_BASE64 != ''
55+ env:
56+ OPENCODE_STORE_BASE64: ${{ secrets.OPENCODE_STORE_BASE64 }}
8057 run: |
8158 STORE_FILE="$RUNNER_TEMP/release.keystore"
8259 printf '%s' "$OPENCODE_STORE_BASE64" | base64 -d > "$STORE_FILE"
8360 echo "OPENCODE_STORE_FILE=$STORE_FILE" >> "$GITHUB_ENV"
8461
8562 - name: Build release APK
86− run: |
87− ./gradlew assembleRelease \
88− -PversionName="${{ steps.version.outputs.version }}" \
89− -PversionCode="${{ steps.version.outputs.code }}" \
90− --no-daemon
63+ env:
64+ OPENCODE_STORE_FILE: ${{ env.OPENCODE_STORE_FILE }}
65+ OPENCODE_STORE_PASSWORD: ${{ secrets.OPENCODE_STORE_PASSWORD }}
66+ OPENCODE_KEY_ALIAS: ${{ secrets.OPENCODE_KEY_ALIAS }}
67+ OPENCODE_KEY_PASSWORD: ${{ secrets.OPENCODE_KEY_PASSWORD }}
68+ run: ./gradlew assembleRelease -PversionName="${{ steps.version.outputs.version }}" --no-daemon
9169
92− # Rename to a stable, version-tagged filename for the release attachment.
70+ # Rename to a stable, version-tagged filename so the release attachment is
71+ # recognizable regardless of the release/ vs release-signed/ output dir.
9372 - name: Stage APK
9473 run: |
9574 mkdir -p staging
96− APK="$(find app/build/outputs/apk/release -name '*.apk' | head -n1)"
75+ APK="$(find app/build/outputs/apk -name '*.apk' | head -n1)"
9776 cp "$APK" "staging/opencode-${{ steps.version.outputs.version }}.apk"
9877
99− # Signed build-provenance attestation for the exact artifact being
100− # published; later verifiable with `gh attestation verify <apk>`.
78+ # Generate a signed build-provenance attestation for the staged APK and
79+ # store it in GitHub's artifact attestation store, keyed by the file's
80+ # digest. Anyone can later verify the APK's origin/provenance with
81+ # `gh attestation verify` -- its repository & workflow. Run *after*
82+ # staging and *before* the release is published so the attested subject
83+ # is byte-identical to the artifact attached to the release.
10184 - name: Attest build provenance
10285 uses: actions/attest-build-provenance@v2
10386 with:
@@ -106,7 +89,7 @@ jobs:
10689 - name: Create release
10790 uses: softprops/action-gh-release@v2
10891 with:
109− tag_name: ${{ steps.version.outputs.tag }}
92+ tag_name: ${{ steps.version.outputs.version }}
11093 name: ${{ steps.version.outputs.version }}
11194 make_latest: true
11295 generate_release_notes: true
.gitignore−19
@@ -1,8 +1,6 @@
11# Gradle
22.gradle/
33build/
4−*.apk
5−*.aab
64
75# Kotlin
86.kotlin/
@@ -31,21 +29,4 @@ Thumbs.db
3129
3230# Nix
3331result
34−result-*
3532.direnv/
36−
37−# Local tooling / editor state (not part of the project)
38−.crush/
39−.crush.db
40−*.log
41−
42−# Rust
43−shared/target/
44−/target/
45−
46−# The `generated/` shared type sources ARE committed (regenerable via
47−# `cargo run --features typegen --bin codegen`); only the built JNI .so
48−# artifacts are ignored.
49−generated/jniLibs/
50−
51−# Gradle wrapper jar is committed so CI can run ./gradlew
AGENTS.mdnew file+197
@@ -0,0 +1,197 @@
1+# AGENTS.md
2+
3+Guidance for AI coding agents (and humans) working in this repository.
4+
5+## Project
6+
7+opencode-android is a native Android client (Kotlin + Jetpack Compose) for the
8+[`opencode serve`](https://opencode.ai/docs/server/) HTTP API. It talks to a
9+remote opencode server over REST plus a long-lived Server-Sent Events stream,
10+and renders conversations (markdown, diffs, tool calls, reasoning, images) with
11+an adaptive single-pane / two-pane Material 3 UI.
12+
13+- Application id / namespace: `soy.iko.opencode`
14+- Package root: `app/src/main/java/soy/iko/opencode`
15+- minSdk 26, compileSdk/targetSdk 37, JDK 17, Kotlin 2.2.x, AGP 9.2.x
16+- Single Gradle module (`:app`); no dynamic feature modules.
17+
18+## Build & environment
19+
20+A Nix flake provides the exact toolchain (JDK 17, Gradle, Android SDK 37, and a
21+Nix-built `aapt2` override because Gradle's bundled aapt2 segfaults on NixOS).
22+
23+```bash
24+nix develop # enter the dev shell (sets ANDROID_HOME, JAVA_HOME, GRADLE_OPTS)
25+./gradlew assembleDebug # -> app/build/outputs/apk/debug/app-debug.apk
26+./gradlew assembleRelease # R8-minified + resource-shrunk; signed only when OPENCODE_STORE_* env vars are set
27+```
28+
29+Outside Nix, you need JDK 17 and the Android SDK (platform 37, build-tools
30+37.0.0) installed manually; `ANDROID_HOME` / `ANDROID_SDK_ROOT` must point at it.
31+
32+Install on a connected device/emulator:
33+
34+```bash
35+adb install -r app/build/outputs/apk/debug/app-debug.apk
36+adb shell am start -n soy.iko.opencode/.MainActivity
37+```
38+
39+## Test, lint, and static analysis
40+
41+```bash
42+./gradlew testDebugUnitTest # JVM unit tests (JSON/reducer/ViewModel logic)
43+./gradlew detekt # static analysis; config: config/detekt/detekt.yml, baseline: app/detekt-baseline.xml
44+./gradlew lintDebug # Android Lint (abortOnError = true)
45+./gradlew connectedAndroidTest # instrumented tests; needs a running AVD or device
46+```
47+
48+- Unit tests live in `app/src/test/...` and run on the JVM. They use fakes from
49+ `app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.kt` (Fake* subclasses
50+ that override the `open` members of the real stores/clients). JUnit 4 +
51+ `kotlinx-coroutines-test`; no Robolectric — `testOptions.unitTests.isReturnDefaultValues = true`
52+ stubs Android framework calls so tests brushing `Log.d` etc. don't need it.
53+- Instrumented tests live in `app/src/androidTest/...` and need a device.
54+- detekt runs with a baseline (`app/detekt-baseline.xml`) so only **new**
55+ findings block CI; existing ones are grandfathered.
56+- CI (`.github/workflows/build.yml`) runs: `testDebugUnitTest` → `detekt` →
57+ `lintDebug` → `assembleDebug` → `assembleRelease`, then uploads both APKs.
58+
59+## Running a server to develop against
60+
61+```bash
62+opencode serve --hostname 0.0.0.0 --port 4096
63+```
64+
65+Connect the app over LAN (`http://<host-ip>:4096`) or USB
66+(`adb reverse tcp:4096 tcp:4096` then `http://127.0.0.1:4096`). Auth is optional
67+(`OPENCODE_SERVER_PASSWORD`; username defaults to `opencode`). See the README's
68+"Set up a server and connect to it" section for full details.
69+
70+## Architecture
71+
72+Unidirectional data flow with manual DI, no annotation processing (no Hilt/KSP).
73+
74+```
75+OpencodeApp (Application)
76+ └─ AppContainer (service locator: profile/settings/draft stores, active connection,
77+ unread tracker, auto-reconnect, pending share / open-session signals)
78+ └─ OpencodeConnection (one per active server profile)
79+ ├─ HttpClient (Ktor + OkHttp, base URL + Basic auth + JSON + SSE)
80+ ├─ OpencodeApiClient (REST: sessions, messages, files, providers, agents, commands, permissions)
81+ ├─ EventStreamClient (long-lived GET /event SSE -> hot SharedFlow<BusEvent>)
82+ └─ SessionRepository (reduces BusEvents + REST seeds -> Flow<List<MessageWithParts>>)
83+ └─ MessageStore (in-memory reducer; internal, unit-tested)
84+
85+ViewModels (ui/<feature>/<Feature>ViewModel.kt)
86+ └─ expose StateFlow; Compose collects with collectAsStateWithLifecycle
87+ └─ built via ui/VmFactory.kt (viewModelFactory { initializer { ... } }) from the AppContainer
88+```
89+
90+Key invariants:
91+
92+- **Subscribe before you prompt.** `SessionRepository.observeMessages` collects
93+ the SSE stream *before* the initial REST load and before any prompt POST, so
94+ early streamed parts aren't missed. Preserve this ordering if you touch it.
95+- **Resilient decoding.** `OpencodeJson` registers `polymorphicDefaultDeserializer`
96+ for `Part`, `BusEvent`, `MessageInfo`, and `ToolState`, so an unknown
97+ discriminator decodes to an `Unknown*` / `ToolUnknown` variant instead of
98+ throwing. Never add a `when (event)` that isn't exhaustive or that crashes on
99+ `UnknownEvent` — the whole point is that a new server release can't break the
100+ client. Add new modeled variants alongside the `Unknown` fallback.
101+- **Conflated message stream.** `observeMessages` is `.conflate()`d so a burst of
102+ per-token snapshots doesn't back-pressure the reducer. Don't remove this.
103+- **Re-seed on SSE reconnect.** When `EventStreamClient.state` returns to
104+ `Connected` after a prior connection, the repository re-fetches messages from
105+ REST and merges (without pruning) under a generation counter so a stale fetch
106+ can't clobber a newer one. See `SessionRepository.kt` for the contract.
107+- **One Ktor client per profile.** `HttpClientFactory.create(profile)` carries
108+ the base URL, JSON negotiation, SSE plugin, and auth. Basic auth is sent
109+ eagerly (no 401 challenge round-trip) and only installed for HTTPS profiles;
110+ for HTTP profiles the `Authorization` header is set proactively in
111+ `defaultRequest` instead, and the reactive Auth plugin is deliberately NOT
112+ installed so credentials can't be re-sent silently on a cleartext 401.
113+- **Process lifetime.** `OpencodeApp` installs a `CrashLogger`, builds the
114+ `AppContainer`, and registers a JVM shutdown hook (since `onTerminate()` isn't
115+ called on real devices) that cancels the app scope and closes the active
116+ connection. `AppContainer.shutdown()` is guarded against double-invocation.
117+
118+## Source map
119+
120+```
121+data/model Wire models (kotlinx.serialization, sealed polymorphism, @Immutable)
122+data/network Ktor: HttpClientFactory, OpencodeApiClient (REST), EventStreamClient (SSE),
123+ OpencodeJson (shared Json), NetworkConfig (all tuning constants)
124+data/repo SessionRepository + MessageStore (reducer), ProfileStore (DataStore +
125+ EncryptedSharedPreferences), SettingsStore (theme/dynamic color),
126+ DraftStore (per-session draft persistence), CrashLogger, ErrorKind
127+di AppContainer (service locator) + OpencodeConnection (per-server bundle)
128+notification NotificationChannels, RunForegroundService (holds process priority
129+ during a run), SessionNotifications (completion notifications)
130+ui OpencodeApp (NavHost), Routes, VmFactory, theme/
131+ui/chat ChatScreen + ChatViewModel, message bubbles, part rendering,
132+ agent/model/command picker sheets, permission dialog, export
133+ui/session SessionListScreen + ViewModel, TwoPaneSessionChat (≥840dp)
134+ui/server ServerListScreen + ServerEditScreen (+ ViewModels)
135+ui/file FileBrowserScreen + FileViewScreen (+ ViewModels)
136+ui/settings SettingsScreen, DiagnosticsScreen (crash reports)
137+util Coroutines.kt (runCatchingCancellable, safeExceptionSummary)
138+```
139+
140+## Conventions
141+
142+- **No comments unless asked** — existing code has extensive `//` comments
143+ explaining *why* (invariants, race conditions, security rationale). When you
144+ add code, match that style only for genuinely non-obvious invariants; don't
145+ narrate *what* the code does.
146+- **`runCatchingCancellable`** (`util/Coroutines.kt`) instead of `runCatching`
147+ inside coroutines — plain `runCatching` swallows `CancellationException` and
148+ breaks structured concurrency. Use it for any try/catch in a suspend context.
149+- **`safeExceptionSummary`** for logging Ktor exceptions — `ClientRequestException`
150+ messages embed the full request URL (may contain auth/paths); never log the
151+ raw exception from an HTTP call. Crash reports scrub URLs via a regex too.
152+- **`open` classes with protected no-arg test constructors.** Stores, the API
153+ client, `EventStreamClient`, `OpencodeConnection`, and `AppContainer` are
154+ `open` and have a `protected constructor()` so tests can subclass and override
155+ individual members without instantiating Android frameworks. Keep this pattern
156+ when adding new injectable components.
157+- **`NetworkConfig`** holds all tuning constants (timeouts, backoffs, buffer
158+ sizes, layout thresholds, debounce delays). Add new magic numbers here rather
159+ than scattering literals.
160+- **REST path encoding.** `OpencodeApiClient` URL-encodes path segments via
161+ `encode()` (e.g. `session/${encode(id)}`). Deep-link session ids are validated
162+ against `[A-Za-z0-9_-]+` in `MainActivity` to prevent path traversal.
163+- **Idempotency keys.** `sendPrompt` and `runCommand` generate the
164+ `Idempotency-Key` header *before* `withRetry` so all retry attempts share it.
165+ Preserve this when adding mutating endpoints.
166+- **`withRetry`** wraps all REST reads and the permission response. Transient
167+ failures back off with jitter; see `NetworkConfig` for the parameters.
168+- **Compose:** `collectAsStateWithLifecycle` everywhere; `WhileSubscribed` with a
169+ 5s grace period (`stateFlowSubscriptionTimeoutMs`). `@Composable` functions
170+ use PascalCase (detekt `FunctionNaming` disabled for this reason). Long
171+ composables and long parameter lists are accepted (detekt `LongMethod` /
172+ `LongParameterList` disabled).
173+- **Manifest:** cleartext HTTP is permitted via `network_security_config.xml`
174+ (opencode serves plain `http://`). `enableOnBackInvokedCallback="true"` for
175+ Android 14 predictive back. Deep link scheme `opencode://session/{id}`.
176+- **`@Immutable`** on data models exposed to Compose so they skip recomposition
177+ correctly.
178+- **Kotlin code style: `official`** (see `gradle.properties`).
179+
180+## Things to avoid
181+
182+- Don't add Hilt/Dagger/KSP — DI is intentionally hand-written (`AppContainer`).
183+- Don't add Robolectric — JVM tests use `isReturnDefaultValues = true` stubs and
184+ fakes; only device-dependent code goes in `androidTest`.
185+- Don't run `nix flake check` manually (CI only — per repo convention).
186+- Don't read or query `/nix/store` (extremely large — per repo convention).
187+- Don't introduce new detekt findings; the baseline only grandfathers existing
188+ ones. Run `./gradlew detekt` before considering work done.
189+- Don't log raw Ktor/OkHttp exceptions — use `safeExceptionSummary`.
190+- Don't make a `when (BusEvent)` / `when (Part)` non-exhaustive; the `Unknown*`
191+ variants exist for forward compatibility and must remain reachable.
192+
193+## Renovate
194+
195+`renovate.json` keeps dependencies current: Kotlin+AGP and Compose are grouped;
196+Ktor is grouped; AndroidX majors are held for manual review (pinned to
197+compileSdk 37). Dependency upgrades land via Renovate PRs, not manual bumps.
CLAUDE.mdmode 100644 → 120000+1−40
@@ -1,40 +0,0 @@
1−# opencode-android
2−
3−Native Android client for the opencode server, built with Crux (Rust core), Kotlin, and Jetpack Compose.
4−
5−## Architecture
6−
7−- **shared/** — Rust Crux core (canonical spec, unit-tested with `cargo test`)
8−- **app/** — Android shell with Kotlin port of the reducer (`OpencodeCore`)
9−- All app logic in `OpencodeCore.kt`; UI is a thin Compose shell
10−- Three screens: Connect → Sessions → Chat
11−
12−## Key Files
13−
14−- `app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt` — reducer, all state management
15−- `app/src/main/java/soy/iko/opencode/core/ViewModel.kt` — UiState, Event, data classes
16−- `app/src/main/java/soy/iko/opencode/core/Protocol.kt` — wire types, SSE event parser
17−- `app/src/main/java/soy/iko/opencode/core/HttpClient.kt` — OkHttp wrapper
18−- `app/src/main/java/soy/iko/opencode/core/SseClient.kt` — SSE stream client
19−- `app/src/main/java/soy/iko/opencode/ui/screens/` — ConnectScreen, SessionsScreen, ChatScreen
20−- `app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt` — message rendering
21−
22−## Build
23−
24−```sh
25−source .buildenv.sh
26−nix develop -c ./gradlew assembleDebug
27−```
28−
29−## Code Conventions
30−
31−- Kotlin + Jetpack Compose (Material 3)
32−- State flows through `MutableStateFlow<UiState>` → `collectAsStateWithLifecycle`
33−- All state mutations on main dispatcher (viewModelScope)
34−- Network I/O on `Dispatchers.IO` inside HttpClient/SseClient
35−- Keep the Rust core and Kotlin port behaviourally identical
36−
37−## Version Control
38−
39−- Uses `jj` (jujutsu) colocated with git
40−- Push to master: `jj git push --change @` to create a bookmark, or commit then push
@@ -0,0 +1 @@
1+AGENTS.md
README.md+64−126
@@ -1,153 +1,91 @@
11# opencode-android
22
3−A native Android client for the [**opencode**](https://opencode.ai) server (the
4−`sst/opencode` AI coding agent), built with **Crux** (a Rust core), **Kotlin**,
5−and **Jetpack Compose**.
3+A native Android client (Kotlin + Jetpack Compose) for the [`opencode serve`](https://opencode.ai/docs/server/) HTTP API.
64
7−Connect to a running `opencode serve`, browse and create sessions, and chat with
8−the agent — assistant replies (text, reasoning, and tool calls) stream in live
9−over the server's SSE event stream.
5+Application id: `soy.iko.opencode`
106
11−<p align="center"><em>Connect → Sessions → Chat, with token-by-token streaming.</em></p>
7+## Set up a server and connect to it
128
13−## Architecture
14−
15−The app follows the Crux pattern: **all application logic lives in a pure,
16−platform-agnostic core; the platform is a thin shell** that renders the core's
17−view model, sends it events, and performs the side effects it requests.
18−
19−```
20−┌─────────────────────────── shared/ (Rust) ───────────────────────────┐
21−│ Crux core — the single source of truth │
22−│ • Model / Event / Effect / ViewModel │
23−│ • update(): connect+auth, sessions, chat, SSE stream application │
24−│ • crux_http for HTTP effects; unit-tested with `cargo test` │
25−└───────────────────────────────────────────────────────────────────────┘
26− ▲ mirrored, behaviour-for-behaviour
27−┌─────────────────────────── app/ (Kotlin) ────────────────────────────┐
28−│ Jetpack Compose shell │
29−│ • OpencodeCore — Kotlin port of the reducer (same ViewModel) │
30−│ • HttpClient (OkHttp) + SseClient (okhttp-sse) — the effects │
31−│ • Compose UI: Connect / Sessions / Chat │
32−└───────────────────────────────────────────────────────────────────────┘
33−```
34−
35−### Why a Kotlin port instead of a JNI `.so`?
36−
37−Crux normally compiles the Rust core to a native library and the shell talks to
38−it over an FFI bridge. That requires an Android cross-compilation toolchain
39−(NDK + a Rust `std` for the `*-linux-android` targets). This project's dev
40−environment intentionally ships **without** an NDK, so instead:
41−
42−- The **Rust core is the canonical specification** of behaviour and is fully
43− unit-tested (`cd shared && cargo test`).
44−- The **Android shell runs `OpencodeCore`**, a faithful Kotlin port of the same
45− reducer, driving an identical `UiState`/`ViewModel`.
46−
47−The FFI boundary is kept clean, so the day an NDK is available the Kotlin port
48−can be swapped for the compiled `.so` behind the same seam. The crate already
49−builds as a `cdylib`/`staticlib` and a `codegen` binary is wired for Crux
50−type-generation.
51−
52−## Project layout
53−
54−```
55−flake.nix Reproducible dev shell (JDK 17, Gradle, Rust, Android SDK 35)
56−shared/ Crux core (Rust)
57− src/app.rs App: Model, Event, Effect, ViewModel, update, view, tests
58− src/protocol.rs Lenient serde wire types for the opencode HTTP + SSE API
59− src/bin/codegen.rs Type-generation entry point (feature = "typegen")
60−generated/ Crux-generated FFI-shared JVM types (from `codegen`)
61−app/ Android app (Kotlin + Compose)
62− src/main/java/soy/iko/opencode/
63− core/ OpencodeCore (reducer port), Protocol, HttpClient, SseClient
64− ui/ Compose screens (Connect/Sessions/Chat), theme, components
65− MainActivity.kt, CoreViewModel.kt
66−```
67−
68−## Getting started
9+This app is a client for the [`opencode serve`](https://opencode.ai/docs/server/) HTTP API, so you
10+need an opencode server running somewhere reachable from your device.
6911
70−### Dev shell
12+### 1. Start the server
7113
72−Everything you need is provided by the Nix flake:
14+On the host that has your project and opencode installed:
7315
74−```sh
75−nix develop # or: direnv allow (an .envrc with `use flake` is included)
16+```bash
17+opencode serve --hostname 0.0.0.0 --port 4096
7618```
7719
78−This puts JDK 17, Gradle, `rustc`/`cargo`, and the Android SDK (platform 35,
79−build-tools 35.0.0) on your `PATH`.
20+Useful flags:
8021
81−### Build & test
22+| Flag | Default | Description |
23+| --- | --- | --- |
24+| `--port` | `4096` | Port to listen on |
25+| `--hostname` | `127.0.0.1` | Hostname/interface to bind (`0.0.0.0` exposes it on your LAN) |
26+| `--mdns` | `false` | Enable mDNS discovery |
27+| `--cors` | `[]` | Extra browser origins to allow (repeatable) |
8228
83−```sh
84−# Rust core — logic + streaming unit tests
85−cd shared && cargo test
86−
87−# Android app — debug APK
88−./gradlew assembleDebug # -> app/build/outputs/apk/debug/app-debug.apk
89−
90−# Android unit tests + lint
91−./gradlew testDebugUnitTest lintDebug
92−```
29+The server publishes an OpenAPI 3.1 spec at `http://<hostname>:<port>/doc`.
9330
94−### Run against a server
31+### 2. (Optional) Add authentication
9532
96−Start opencode's server on your machine or LAN:
33+opencode has no auth by default. To protect the server with HTTP Basic auth:
9734
98−```sh
99−opencode serve --port 4096 # optionally behind OPENCODE_SERVER_PASSWORD
35+```bash
36+OPENCODE_SERVER_PASSWORD=your-password opencode serve --hostname 0.0.0.0 --port 4096
10037```
10138
102−Install the APK, launch the app, and enter the server URL (e.g.
103−`http://192.168.1.10:4096`). If the server was started with a password the app
104−detects the `401`, shows credential fields, and connects with HTTP Basic auth.
39+The username defaults to `opencode` (override with `OPENCODE_SERVER_USERNAME`). Enter these in the
40+app's add-server dialog.
10541
106−> Cleartext HTTP is enabled so you can reach a `http://` server on your LAN.
42+### 3. Connect from the app
10743
108−## The opencode API this client speaks
44+**Over Wi-Fi / LAN** — add a server with base URL `http://<host-LAN-ip>:4096`.
10945
110−- **Connect / auth** — `GET /global/health` probes reachability; a `401` triggers
111− HTTP Basic auth.
112−- **Sessions** — `GET /session`, `POST /session`, `DELETE /session/{id}`.
113−- **Messages** — `GET /session/{id}/message` (each item is `{ info, parts }`).
114−- **Send** — `POST /session/{id}/prompt_async` (returns immediately; the reply
115− streams over SSE).
116−- **Cancel** — `POST /session/{id}/abort`.
117−- **Stream** — `GET /event` (SSE). The client applies `message.updated`,
118− `message.part.updated` (text/reasoning/tool, with deltas), `message.removed`,
119− `session.idle` (clears the "generating" state), `session.error`, and
120− `session.{created,updated,deleted}` incrementally. Unknown event types are
121− ignored so new server versions don't break the stream.
46+**Over USB only** — forward the port to the device and point the app at localhost:
12247
123−## Continuous integration & releases
124−
125−- **CI** (`.github/workflows/build.yml`) runs on pushes to `master` and PRs: it
126− unit-tests the Crux core (`cargo test`), runs the Android unit tests + lint,
127− and builds the debug APK (uploaded as an artifact).
128−- **Releases** (`.github/workflows/release.yml`): push a version tag to cut a
129− release — it builds a minified, signed release APK and publishes a GitHub
130− Release with the APK attached and a signed build-provenance attestation.
131−
132− ```sh
133− git tag v1.0.0 && git push origin v1.0.0 # or run the workflow manually
134− ```
48+```bash
49+adb reverse tcp:4096 tcp:4096
50+```
13551
136− The tag name becomes the version (a manual run uses a `YYYY.MM.DD.HHMM`
137− timestamp), stamped into the APK via `-PversionName/-PversionCode`.
52+Then use base URL `http://127.0.0.1:4096` in the app.
13853
139− **Signing:** set the repo secrets `OPENCODE_STORE_BASE64` (base64 of your
140− keystore), `OPENCODE_STORE_PASSWORD`, `OPENCODE_KEY_ALIAS`, and
141− `OPENCODE_KEY_PASSWORD` to sign with your own key. Without them the release
142− build falls back to the debug key, so the APK is still installable.
54+> The app permits cleartext HTTP because opencode serves plain `http://`. If you terminate TLS with
55+> a reverse proxy, use an `https://` base URL instead.
14356
144−## Notes & limitations
57+## Architecture
14558
146−- Rendering covers text, reasoning, and tool-call parts. Interactive tool
147− **permission** prompts (`permission.updated`) are not yet surfaced.
148−- Model/agent selection uses the server's configured default (no picker yet).
149−- No `.so` is built in this environment (see *Architecture* above).
59+- **Ktor 3 (OkHttp engine)** for REST + the long-lived `GET /event` SSE stream.
60+- **kotlinx.serialization** with sealed polymorphism for opencode's discriminated unions
61+ (`Part`/`type`, `MessageInfo`/`role`, `ToolState`/`status`, `BusEvent`/`type`). Unknown
62+ discriminator values fall back to `Unknown*` variants (`OpencodeJson`) so a new server release
63+ can't break decoding.
64+- **Manual DI** (`AppContainer` on the `Application`) — no Hilt/KSP.
65+- **Unidirectional state**: `EventStreamClient` exposes the SSE stream as a hot `SharedFlow`;
66+ `SessionRepository` reduces events into per-session message state; ViewModels expose `StateFlow`;
67+ Compose collects with `collectAsStateWithLifecycle`.
68+- The repository subscribes to `/event` **before** the prompt POST so no early streamed part is missed.
69+
70+Source map: `data/network` (Ktor, SSE, JSON), `data/model` (wire models), `data/repo`
71+(reducer + profile persistence), `di` (container/connection), `ui/<feature>` (Compose screens).
72+
73+## Build
74+
75+```bash
76+nix develop # JDK 17, Gradle 9.6.1, Android SDK 37, aapt2 override
77+./gradlew assembleDebug # -> app/build/outputs/apk/debug/app-debug.apk
78+./gradlew assembleRelease # R8-minified + resource-shrunk release APK
79+./gradlew testDebugUnitTest # JVM tests for the JSON/serialization layer
80+./gradlew detekt # static analysis (config: config/detekt/detekt.yml)
81+./gradlew lintDebug # Android Lint
82+./gradlew connectedAndroidTest # device/emulator tests (needs an AVD)
83+```
15084
151−## License
85+## Install & run
15286
153−Apache-2.0.
87+```bash
88+adb install -r app/build/outputs/apk/debug/app-debug.apk
89+adb shell am start -n soy.iko.opencode/.MainActivity
90+adb logcat --pid=$(adb shell pidof soy.iko.opencode)
91+```
app/build.gradle.kts+116−46
@@ -1,59 +1,66 @@
11plugins {
22 alias(libs.plugins.android.application)
3− alias(libs.plugins.kotlin.android)
4− alias(libs.plugins.kotlin.compose)
53 alias(libs.plugins.kotlin.serialization)
4+ alias(libs.plugins.kotlin.compose)
5+ alias(libs.plugins.detekt)
66}
77
8−// Version is overridable from the release workflow via
9−// `-PversionName=... -PversionCode=...`; falls back to sensible defaults locally.
10−val appVersionName = (project.findProperty("versionName") as String?) ?: "0.1.0"
11−val appVersionCode = (project.findProperty("versionCode") as String?)?.toIntOrNull() ?: 1
8+/**
9+ * Monotonically increasing versionCode: the git commit count on HEAD, so each release
10+ * ships a strictly higher code (never a "downgrade" block) without manual bookkeeping.
11+ * Falls back to 1 when git or the history isn't available (e.g. a plain source export).
12+ */
13+val gitCommitCount: Int = runCatching {
14+ providers.exec {
15+ commandLine("git", "rev-list", "--count", "HEAD")
16+ }.standardOutput.asText.get().trim().toInt()
17+}.getOrDefault(1)
1218
13−// A release keystore is supplied by CI (decoded from a secret) via env vars.
14−// When it's absent — local builds, or CI before signing is configured — the
15−// release build falls back to the debug signing config so the APK is still
16−// installable, rather than producing an uninstallable unsigned APK.
17−val releaseStorePath: String? = System.getenv("OPENCODE_STORE_FILE")
18−val hasReleaseKeystore = releaseStorePath != null && file(releaseStorePath).exists()
19+// Optional versionName override supplied via `-PversionName=...` (used by the
20+// release workflow to stamp the commit-date version into the APK). Falls back to
21+// the hard-coded version when absent so local builds are unaffected.
22+val versionNameOverride: String? = providers.gradleProperty("versionName").orNull
1923
2024android {
2125 namespace = "soy.iko.opencode"
22− compileSdk = 35
26+ compileSdk = 37
27+ buildToolsVersion = "37.0.0"
2328
2429 defaultConfig {
2530 applicationId = "soy.iko.opencode"
2631 minSdk = 26
27− targetSdk = 35
28− versionCode = appVersionCode
29− versionName = appVersionName
30−
32+ targetSdk = 37
33+ // Monotonically increasing across builds: derived from the git commit count so
34+ // each release ships a strictly higher versionCode without manual bookkeeping
35+ // (and never triggers a "downgrade" install block). Falls back to 1 when the
36+ // .git history isn't available (e.g. a plain source export).
37+ versionCode = gitCommitCount
38+ versionName = versionNameOverride ?: "0.1.0"
3139 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
3240 }
3341
34− signingConfigs {
35− if (hasReleaseKeystore) {
36− create("release") {
37− storeFile = file(releaseStorePath!!)
38− storePassword = System.getenv("OPENCODE_STORE_PASSWORD")
39− keyAlias = System.getenv("OPENCODE_KEY_ALIAS")
40− keyPassword = System.getenv("OPENCODE_KEY_PASSWORD")
41− }
42+ // Release builds are signed when keystore credentials are supplied via env vars
43+ // (so CI can publish an installable APK). When the env vars are absent, the
44+ // release build falls back to unsigned — same behavior as before — so local
45+ // builds keep working without any keystore setup.
46+ val releaseSigning = providers.environmentVariable("OPENCODE_STORE_FILE").orNull?.let { path ->
47+ signingConfigs.create("release") {
48+ storeFile = file(path)
49+ storePassword = providers.environmentVariable("OPENCODE_STORE_PASSWORD").orNull
50+ keyAlias = providers.environmentVariable("OPENCODE_KEY_ALIAS").orNull
51+ keyPassword = providers.environmentVariable("OPENCODE_KEY_PASSWORD").orNull
4252 }
4353 }
4454
4555 buildTypes {
4656 release {
4757 isMinifyEnabled = true
58+ isShrinkResources = true
4859 proguardFiles(
4960 getDefaultProguardFile("proguard-android-optimize.txt"),
5061 "proguard-rules.pro",
5162 )
52− signingConfig = if (hasReleaseKeystore) {
53− signingConfigs.getByName("release")
54− } else {
55− signingConfigs.getByName("debug")
56− }
63+ releaseSigning?.let { signingConfig = it }
5764 }
5865 }
5966
@@ -62,42 +69,105 @@ android {
6269 targetCompatibility = JavaVersion.VERSION_17
6370 }
6471
65− kotlinOptions {
66− jvmTarget = "17"
72+ // Use the kotlin { compilerOptions {} } block (the kotlinOptions DSL is deprecated
73+ // in favor of the type-safe extension exposed by the Kotlin Gradle plugin).
74+ kotlin {
75+ compilerOptions {
76+ jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
77+ }
6778 }
6879
6980 buildFeatures {
7081 compose = true
7182 }
7283
84+ lint {
85+ // Fail CI on real errors. Warnings are reported but don't break the build so
86+ // minor/style issues don't gate development.
87+ abortOnError = true
88+ // Dependency upgrades are tracked by Renovate, so don't lint for them here.
89+ // ProduceStateDoesNotAssignValue false-positives on valid produceState calls that
90+ // assign `value` via a conditional/`withContext` expression (see FileViewScreen's
91+ // match-index scan), so it's disabled rather than worked around at each call site.
92+ // InvalidFragmentVersionForActivityResult false-positives on FragmentActivity
93+ // (the app doesn't use fragments, but BiometricPrompt requires FragmentActivity).
94+ // LocalContextGetResourceValueCall flags context.getString() inside LaunchedEffect /
95+ // remember lambdas where stringResource() can't be called (not a @Composable scope);
96+ // the check is overly aggressive for valid non-composable coroutine contexts.
97+ disable += setOf(
98+ "GradleDependency",
99+ "AndroidGradlePluginVersion",
100+ "ProduceStateDoesNotAssignValue",
101+ "InvalidFragmentVersionForActivityResult",
102+ "LocalContextGetResourceValueCall",
103+ )
104+ }
105+
73106 packaging {
74107 resources {
75108 excludes += "/META-INF/{AL2.0,LGPL2.1}"
76109 }
77110 }
111+
112+ testOptions {
113+ unitTests {
114+ // Return default values for Android framework stubs (e.g. Log.d, Context.getString)
115+ // instead of throwing "not mocked" exceptions, so JVM unit tests that brush
116+ // against Android APIs don't need Robolectric.
117+ isReturnDefaultValues = true
118+ isIncludeAndroidResources = true
119+ }
120+ }
78121}
79122
80123dependencies {
81124 implementation(libs.androidx.core.ktx)
82− implementation(libs.androidx.lifecycle.runtime.ktx)
83− implementation(libs.androidx.lifecycle.runtime.compose)
84− implementation(libs.androidx.lifecycle.viewmodel.ktx)
85− implementation(libs.androidx.lifecycle.viewmodel.compose)
125+ implementation(libs.androidx.core.splashscreen)
126+ implementation(libs.androidx.window)
86127 implementation(libs.androidx.activity.compose)
128+ implementation(libs.androidx.lifecycle.viewmodel.compose)
129+ implementation(libs.androidx.lifecycle.runtime.compose)
130+ implementation(libs.androidx.navigation.compose)
87131
88− implementation(platform(libs.androidx.compose.bom))
89− implementation(libs.androidx.compose.ui)
90− implementation(libs.androidx.compose.ui.graphics)
91− implementation(libs.androidx.compose.ui.tooling.preview)
92− implementation(libs.androidx.compose.material3)
93− implementation(libs.androidx.compose.material.icons.extended)
94− debugImplementation(libs.androidx.compose.ui.tooling)
132+ implementation(platform(libs.compose.bom))
133+ implementation(libs.compose.ui)
134+ implementation(libs.compose.ui.graphics)
135+ implementation(libs.compose.ui.tooling.preview)
136+ implementation(libs.compose.material3)
137+ implementation(libs.compose.material.icons.extended)
138+ debugImplementation(libs.compose.ui.tooling)
139+
140+ implementation(libs.ktor.client.core)
141+ implementation(libs.ktor.client.okhttp)
142+ implementation(libs.ktor.client.content.negotiation)
143+ implementation(libs.ktor.serialization.kotlinx.json)
144+ implementation(libs.ktor.client.auth)
95145
96− implementation(libs.kotlinx.coroutines.android)
97146 implementation(libs.kotlinx.serialization.json)
98− implementation(libs.okhttp)
99− implementation(libs.okhttp.sse)
147+ implementation(libs.kotlinx.coroutines.android)
148+
149+ implementation(libs.androidx.datastore.preferences)
150+ implementation(libs.androidx.security.crypto)
151+ implementation(libs.androidx.biometric)
152+ implementation(libs.coil.compose)
153+ implementation(libs.coil.network.okhttp)
154+ implementation(libs.markdown.renderer)
155+ implementation(libs.markdown.renderer.m3)
100156
101157 testImplementation(libs.junit)
102158 testImplementation(libs.kotlinx.coroutines.test)
159+ testImplementation(libs.ktor.client.mock)
160+
161+ androidTestImplementation(libs.androidx.test.ext.junit)
162+ androidTestImplementation(libs.androidx.test.runner)
163+ androidTestImplementation(libs.androidx.test.core)
164+ androidTestImplementation(libs.kotlinx.coroutines.test)
165+}
166+
167+detekt {
168+ buildUponDefaultConfig = true
169+ // Carry forward the current baseline so CI only fails on *new* findings;
170+ // existing issues are grandfathered rather than blocking the migration.
171+ baseline = file("detekt-baseline.xml")
172+ config.setFrom("$rootDir/config/detekt/detekt.yml")
103173}
app/detekt-baseline.xmlnew file+33
@@ -0,0 +1,33 @@
1+<?xml version="1.0" ?>
2+<SmellBaseline>
3+ <ManuallySuppressedIssues/>
4+ <CurrentIssues>
5+ <ID>ComplexCondition:ChatScreen.kt$searchActive &amp;&amp; searchQuery.isNotEmpty() &amp;&amp; searchMessages.isEmpty() &amp;&amp; messages.isNotEmpty()</ID>
6+ <ID>ComplexCondition:PartRenderer.kt$part.isImage &amp;&amp; imageContext != null &amp;&amp; (part.source != null || !part.url.isNullOrBlank())</ID>
7+ <ID>ComplexCondition:SessionListViewModel.kt$SessionListState$q.isEmpty() &amp;&amp; pinnedIds.isEmpty() &amp;&amp; noArchivedHidden &amp;&amp; directoryFilter == null</ID>
8+ <ID>CyclomaticComplexMethod:AppContainer.kt$AppContainer$@OptIn(ExperimentalCoroutinesApi::class) private fun observeMessageActivity()</ID>
9+ <ID>CyclomaticComplexMethod:ChatScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatScreen( container: AppContainer, sessionId: String, onBack: () -&gt; Unit, onOpenFile: ((String) -&gt; Unit)? = null, onOpenSession: ((String) -&gt; Unit)? = null, focusMessageId: String? = null, onEditProfile: ((String) -&gt; Unit)? = null, )</ID>
10+ <ID>CyclomaticComplexMethod:CodeHighlighter.kt$fun highlightLine(line: String, syntax: FileSyntax, palette: HighlightPalette, state: BlockCommentState): AnnotatedString</ID>
11+ <ID>CyclomaticComplexMethod:CodeHighlighter.kt$private fun findNumberEnd(line: String, start: Int): Int</ID>
12+ <ID>CyclomaticComplexMethod:ConversationExport.kt$fun buildConversationMarkdown(messages: List&lt;MessageWithParts&gt;, title: String?): String</ID>
13+ <ID>CyclomaticComplexMethod:DiagnosticsScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun DiagnosticsScreen(onBack: () -&gt; Unit)</ID>
14+ <ID>CyclomaticComplexMethod:EventStreamClient.kt$EventStreamClient$private fun stream(): Flow&lt;BusEvent&gt;</ID>
15+ <ID>CyclomaticComplexMethod:FileViewScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun FileViewScreen( container: AppContainer, path: String, onBack: () -&gt; Unit, initialLine: Int? = null, )</ID>
16+ <ID>CyclomaticComplexMethod:GlobalSearchScreen.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun GlobalSearchScreen( container: AppContainer, onOpenSession: (String, String?) -&gt; Unit, onBack: () -&gt; Unit, )</ID>
17+ <ID>CyclomaticComplexMethod:MainActivity.kt$MainActivity$private fun handleIntent(intent: Intent?)</ID>
18+ <ID>CyclomaticComplexMethod:OpencodeApp.kt$@Composable fun OpencodeApp(container: AppContainer)</ID>
19+ <ID>CyclomaticComplexMethod:ServerListScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ServerListScreen( container: AppContainer, onConnected: () -&gt; Unit, onAddProfile: () -&gt; Unit, onServerSettings: (String) -&gt; Unit, )</ID>
20+ <ID>CyclomaticComplexMethod:SessionListScreen.kt$@OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class) @Composable fun SessionListScreen( container: AppContainer, onOpenSession: (String) -&gt; Unit, onDisconnect: () -&gt; Unit, onOpenFiles: () -&gt; Unit, onOpenSettings: () -&gt; Unit, onAddServer: () -&gt; Unit, onOpenSearch: () -&gt; Unit = {}, onEditProfile: ((String) -&gt; Unit)? = null, // Incremented by a host (the two-pane empty-detail pane) to open the new-session // directory picker, so that pane shares this screen's dialog instead of bypassing it. externalNewSessionTrigger: Int = 0, selectedSessionId: String? = null, )</ID>
21+ <ID>CyclomaticComplexMethod:SettingsScreen.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalCoroutinesApi::class) @Composable fun SettingsScreen( container: AppContainer, onBack: () -&gt; Unit, onManageServers: () -&gt; Unit, onOpenDiagnostics: () -&gt; Unit, onOpenUsage: () -&gt; Unit = {}, onOpenMcp: () -&gt; Unit = {}, onEditProfile: ((String) -&gt; Unit)? = null, )</ID>
22+ <ID>LargeClass:AppContainer.kt$AppContainer</ID>
23+ <ID>LargeClass:SessionListViewModel.kt$SessionListViewModel : ViewModel</ID>
24+ <ID>MatchingDeclarationName:DiffView.kt$DiffLine</ID>
25+ <ID>MatchingDeclarationName:RemoteImage.kt$ImageLoadContext</ID>
26+ <ID>TooManyFunctions:AppContainer.kt$AppContainer</ID>
27+ <ID>TooManyFunctions:ChatViewModel.kt$ChatViewModel : ViewModel</ID>
28+ <ID>TooManyFunctions:CrashLogger.kt$CrashLogger</ID>
29+ <ID>TooManyFunctions:MessageBubble.kt$soy.iko.opencode.ui.chat.MessageBubble.kt</ID>
30+ <ID>TooManyFunctions:OpencodeApiClient.kt$OpencodeApiClient</ID>
31+ <ID>TooManyFunctions:SessionNotifications.kt$SessionNotifications</ID>
32+ </CurrentIssues>
33+</SmellBaseline>
app/proguard-rules.pro+48−13
@@ -1,16 +1,51 @@
1−# kotlinx.serialization — keep generated serializers and @Serializable metadata.
2−-keepattributes *Annotation*, InnerClasses
1+# --- kotlinx.serialization ---
2+# The plugin generates serializers; keep them and the $$serializer companion members.
3+-keepattributes *Annotation*, InnerClasses, Signature, Exceptions
34-dontnote kotlinx.serialization.**
4−
5−-keepclassmembers class **$$serializer { *; }
6−-keepclasseswithmembers class * {
7− @kotlinx.serialization.Serializable <methods>;
5+-keepclassmembers class soy.iko.opencode.** {
6+ *** Companion;
7+}
8+-keepclasseswithmembers class soy.iko.opencode.** {
9+ kotlinx.serialization.KSerializer serializer(...);
10+}
11+# Keep @Serializable classes themselves so their generated serializers can reference them.
12+-keep,includedescriptorclasses class soy.iko.opencode.**$$serializer { *; }
13+-keepclassmembers class soy.iko.opencode.** {
14+ *** Companion;
815}
9−-if @kotlinx.serialization.Serializable class **
10−-keep, allowobfuscation, allowshrinking class <1>
16+# Keep the serializable model classes (reflection-free serializer lookup needs the class).
17+# Covers ALL @Serializable classes in the app — not just data.model — because several
18+# classes used for persistent storage live elsewhere (OutboxMessage/PersistedAttachment in
19+# data/repo, StoredProfile in ProfileStore, Backup* in BackupManager, RecentSession, …)
20+# and R8 stripping/renaming their fields would break on-disk deserialization in release.
21+-keep @kotlinx.serialization.Serializable class soy.iko.opencode.** { *; }
22+
23+# --- Ktor / OkHttp (HTTP client + SSE) ---
24+# Don't warn about missing optional dependencies; keep only the classes that are
25+# accessed via reflection (Ktor engine registration, OkHttp internals that R8
26+# would otherwise strip). Narrow keeps instead of `-keep class io.ktor.** { *; }`
27+# so R8 can still eliminate dead code and shrink the APK.
28+-dontwarn io.ktor.**
29+-dontwarn okhttp3.**
30+-dontwarn okio.**
31+-dontwarn org.slf4j.**
32+-dontwarn org.jetbrains.annotations.**
33+# Ktor loads engines and plugins via ServiceLoader — keep the service files.
34+-keep class io.ktor.client.engine.** { *; }
35+-keep class io.ktor.client.plugins.** { *; }
36+-keep class io.ktor.serialization.** { *; }
37+-keep class io.ktor.http.** { *; }
38+# OkHttp uses reflection for platform detection and internal classes.
39+-keep class okhttp3.internal.platform.** { *; }
40+-keep class okhttp3.** { *; }
41+-keep class okio.** { *; }
42+
43+# --- Coil (image loading) ---
44+# Coil uses reflection for loadable decoders and the Compose integration.
45+-keep class coil.** { *; }
46+-keep class coil3.** { *; }
47+-dontwarn coil.**
1148
12−# OkHttp / okio — platform-specific bits referenced reflectively.
13−-dontwarn okhttp3.internal.platform.**
14−-dontwarn org.conscrypt.**
15−-dontwarn org.bouncycastle.**
16−-dontwarn org.openjsse.**
49+# --- androidx.security.crypto pulls in Tink, which references compile-only
50+# com.google.errorprone annotations. They aren't on the runtime classpath.
51+-dontwarn com.google.errorprone.annotations.**
app/src/androidTest/java/soy/iko/opencode/ApplicationBootstrapTest.ktnew file+36
@@ -0,0 +1,36 @@
1+package soy.iko.opencode
2+
3+import androidx.test.core.app.ApplicationProvider
4+import androidx.test.ext.junit.runners.AndroidJUnit4
5+import org.junit.Assert.assertNotNull
6+import org.junit.Assert.assertTrue
7+import org.junit.Test
8+import org.junit.runner.RunWith
9+
10+/**
11+ * Smoke test for the [OpencodeApp] Application bootstrap: the container and crash logger
12+ * are initialized on startup, and the notification channels are created.
13+ */
14+@RunWith(AndroidJUnit4::class)
15+class ApplicationBootstrapTest {
16+
17+ @Test
18+ fun containerIsInitialized() {
19+ val app = ApplicationProvider.getApplicationContext<OpencodeApp>()
20+ assertNotNull(app.container)
21+ assertNotNull(app.container.settingsStore)
22+ }
23+
24+ @Test
25+ fun notificationChannelsExist() {
26+ if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.O) return
27+ val context = ApplicationProvider.getApplicationContext<android.content.Context>()
28+ // Touching the app instance runs Application.onCreate, which creates the channels.
29+ ApplicationProvider.getApplicationContext<OpencodeApp>()
30+ val nm = context.getSystemService(android.content.Context.NOTIFICATION_SERVICE)
31+ as android.app.NotificationManager
32+ val ids = nm.notificationChannels.map { it.id }
33+ assertTrue("status channel should exist, got $ids", ids.contains("run_status"))
34+ assertTrue("completed channel should exist, got $ids", ids.contains("session_completed"))
35+ }
36+}
app/src/androidTest/java/soy/iko/opencode/data/repo/CrashLoggerTest.ktnew file+67
@@ -0,0 +1,67 @@
1+package soy.iko.opencode.data.repo
2+
3+import androidx.test.core.app.ApplicationProvider
4+import androidx.test.ext.junit.runners.AndroidJUnit4
5+import org.junit.Assert.assertEquals
6+import org.junit.Assert.assertNotNull
7+import org.junit.Assert.assertNull
8+import org.junit.Assert.assertTrue
9+import org.junit.Test
10+import org.junit.runner.RunWith
11+
12+/**
13+ * Instrumented test for [CrashLogger]. Runs on a device/emulator so it can exercise the
14+ * real app-private filesystem: a simulated crash is written, then read back, listed, and
15+ * deleted through the public API.
16+ */
17+@RunWith(AndroidJUnit4::class)
18+class CrashLoggerTest {
19+
20+ private val context = ApplicationProvider.getApplicationContext<android.content.Context>()
21+ private val logger = CrashLogger.get(context)
22+
23+ @Test
24+ fun writeReadDeleteRoundTrip() {
25+ logger.clearAll()
26+
27+ // Simulate an uncaught exception by invoking the report path directly.
28+ val report = writeFakeReport()
29+
30+ logger.refresh()
31+ assertEquals(1, logger.reportCount())
32+
33+ val content = logger.readReport(report)
34+ assertNotNull(content)
35+ assertTrue("report should contain the stack trace", content!!.contains("IllegalStateException"))
36+ assertTrue("report should contain device metadata", content.contains("Android:"))
37+
38+ logger.deleteReport(report)
39+ assertEquals(0, logger.reportCount())
40+ assertNull(logger.readReport(report))
41+ }
42+
43+ @Test
44+ fun clearAllRemovesEveryReport() {
45+ writeFakeReport("a")
46+ writeFakeReport("b")
47+ logger.refresh()
48+ assertTrue(logger.reportCount() >= 2)
49+
50+ logger.clearAll()
51+ assertEquals(0, logger.reportCount())
52+ }
53+
54+ private fun writeFakeReport(tag: String = "test"): String {
55+ val dir = java.io.File(context.filesDir, "crashes").apply { mkdirs() }
56+ val name = "crash-test-${tag}-${System.nanoTime()}.txt"
57+ java.io.File(dir, name).writeText(
58+ """
59+ opencode-android crash report
60+ Android: test
61+ java.lang.IllegalStateException: boom $tag
62+ at Crasher.crash(Crasher.kt:1)
63+ """.trimIndent(),
64+ )
65+ return name
66+ }
67+}
app/src/androidTest/java/soy/iko/opencode/data/repo/DraftStoreTest.ktnew file+59
@@ -0,0 +1,59 @@
1+package soy.iko.opencode.data.repo
2+
3+import androidx.test.core.app.ApplicationProvider
4+import androidx.test.ext.junit.runners.AndroidJUnit4
5+import kotlinx.coroutines.CoroutineScope
6+import kotlinx.coroutines.Dispatchers
7+import kotlinx.coroutines.SupervisorJob
8+import kotlinx.coroutines.cancel
9+import kotlinx.coroutines.runBlocking
10+import org.junit.Assert.assertEquals
11+import org.junit.Test
12+import org.junit.runner.RunWith
13+
14+@RunWith(AndroidJUnit4::class)
15+class DraftStoreTest {
16+
17+ private val context = ApplicationProvider.getApplicationContext<android.content.Context>()
18+
19+ @Test
20+ fun setGetRemoveRoundTrip() = runBlocking {
21+ val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
22+ val store = DraftStore(context, scope)
23+
24+ // Use a unique session ID so it doesn't collide with other tests.
25+ val sid = "test-${System.nanoTime()}"
26+
27+ store.set(sid, "hello world")
28+ assertEquals("hello world", store.get(sid))
29+
30+ // Overwrite
31+ store.set(sid, "updated")
32+ assertEquals("updated", store.get(sid))
33+
34+ // Blank clears the entry
35+ store.set(sid, " ")
36+ assertEquals("", store.get(sid))
37+
38+ // Explicit remove
39+ store.set(sid, "text")
40+ store.remove(sid)
41+ assertEquals("", store.get(sid))
42+
43+ scope.cancel()
44+ }
45+
46+ @Test
47+ fun getReturnsEmptyForUnknownSession() {
48+ // Create the scope in a local val so it can be cancelled — DraftStore launches a
49+ // debounced-persistence collector into the supplied scope that keeps running until
50+ // cancelled, and an uncanceled scope leaks across test invocations on device.
51+ val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
52+ try {
53+ val store = DraftStore(context, scope)
54+ assertEquals("", store.get("never-set-${System.nanoTime()}"))
55+ } finally {
56+ scope.cancel()
57+ }
58+ }
59+}
app/src/main/AndroidManifest.xml+115−6
@@ -1,26 +1,135 @@
11<?xml version="1.0" encoding="utf-8"?>
2−<manifest xmlns:android="http://schemas.android.com/apk/res/android">
2+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
3+ xmlns:tools="http://schemas.android.com/tools">
34
45 <uses-permission android:name="android.permission.INTERNET" />
56 <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
7+ <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
8+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
9+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
10+
11+ <!-- Camera is used opportunistically for attachment capture via the system camera app
12+ (ACTION_IMAGE_CAPTURE), so it's optional — devices without a camera still install. -->
13+ <uses-feature android:name="android.hardware.camera.any" android:required="false" />
614
715 <application
8− android:allowBackup="true"
9− android:icon="@android:drawable/sym_def_app_icon"
16+ android:name=".OpencodeApp"
17+ android:allowBackup="false"
18+ android:dataExtractionRules="@xml/data_extraction_rules"
19+ android:fullBackupContent="false"
20+ android:icon="@mipmap/ic_launcher"
1021 android:label="@string/app_name"
22+ android:networkSecurityConfig="@xml/network_security_config"
1123 android:supportsRtl="true"
12− android:theme="@style/Theme.OpencodeAndroid"
13− android:usesCleartextTraffic="true">
24+ android:theme="@style/Theme.OpencodeAndroid">
1425
1526 <activity
1627 android:name=".MainActivity"
1728 android:exported="true"
29+ android:launchMode="singleTop"
30+ android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboardHidden|uiMode|navigation"
31+ android:enableOnBackInvokedCallback="true"
1832 android:windowSoftInputMode="adjustResize"
19− android:theme="@style/Theme.OpencodeAndroid">
33+ tools:targetApi="33"
34+ android:theme="@style/Theme.OpencodeAndroid.Splash">
2035 <intent-filter>
2136 <action android:name="android.intent.action.MAIN" />
2237 <category android:name="android.intent.category.LAUNCHER" />
2338 </intent-filter>
39+ <intent-filter>
40+ <action android:name="android.intent.action.SEND" />
41+ <category android:name="android.intent.category.DEFAULT" />
42+ <data android:mimeType="text/plain" />
43+ </intent-filter>
44+ <!-- Receive images shared from other apps (single or multiple) to attach to a prompt. -->
45+ <intent-filter>
46+ <action android:name="android.intent.action.SEND" />
47+ <category android:name="android.intent.category.DEFAULT" />
48+ <data android:mimeType="image/*" />
49+ </intent-filter>
50+ <intent-filter>
51+ <action android:name="android.intent.action.SEND_MULTIPLE" />
52+ <category android:name="android.intent.category.DEFAULT" />
53+ <data android:mimeType="image/*" />
54+ </intent-filter>
55+ <!-- Deep link to a session: opencode://session/{sessionId} -->
56+ <intent-filter>
57+ <action android:name="android.intent.action.VIEW" />
58+ <category android:name="android.intent.category.DEFAULT" />
59+ <category android:name="android.intent.category.BROWSABLE" />
60+ <data android:scheme="opencode" android:host="session" />
61+ </intent-filter>
62+ <!-- Deep links: opencode://file/{path} and opencode://new -->
63+ <intent-filter>
64+ <action android:name="android.intent.action.VIEW" />
65+ <category android:name="android.intent.category.DEFAULT" />
66+ <category android:name="android.intent.category.BROWSABLE" />
67+ <data android:scheme="opencode" android:host="file" />
68+ </intent-filter>
69+ <intent-filter>
70+ <action android:name="android.intent.action.VIEW" />
71+ <category android:name="android.intent.category.DEFAULT" />
72+ <category android:name="android.intent.category.BROWSABLE" />
73+ <data android:scheme="opencode" android:host="new" />
74+ </intent-filter>
2475 </activity>
76+
77+ <service
78+ android:name=".notification.RunForegroundService"
79+ android:exported="false"
80+ android:foregroundServiceType="dataSync" />
81+
82+ <!-- Handles notification action buttons (permission responses, inline reply). Not
83+ exported: only the app's own PendingIntents target it. -->
84+ <receiver
85+ android:name=".notification.NotificationActionReceiver"
86+ android:exported="false" />
87+
88+ <!-- Home-screen widget listing recent sessions. -->
89+ <receiver
90+ android:name=".platform.SessionsWidgetProvider"
91+ android:exported="true">
92+ <intent-filter>
93+ <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
94+ </intent-filter>
95+ <meta-data
96+ android:name="android.appwidget.provider"
97+ android:resource="@xml/sessions_widget_info" />
98+ </receiver>
99+
100+ <!-- Serves the widget's session list rows. Bound by the system with BIND_REMOTEVIEWS.
101+ exported="true" is required: the AppWidget host runs in the launcher/system process
102+ (a different process from the app), so it can only bindService to a service that's
103+ visible outside the app. The BIND_REMOTEVIEWS permission restricts *who* can bind to
104+ the system; exported="false" would make the service invisible to the host and the
105+ widget's RemoteViewsService adapter would never populate. -->
106+ <service
107+ android:name=".platform.SessionsWidgetService"
108+ android:permission="android.permission.BIND_REMOTEVIEWS"
109+ android:exported="true" />
110+
111+ <!-- Quick-settings tile: start a new session. Bound by the system. -->
112+ <service
113+ android:name=".platform.NewSessionTileService"
114+ android:icon="@drawable/ic_launcher_foreground"
115+ android:label="@string/tile_new_session"
116+ android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
117+ android:exported="true">
118+ <intent-filter>
119+ <action android:name="android.service.quicksettings.action.QS_TILE" />
120+ </intent-filter>
121+ </service>
122+
123+ <!-- Serves camera-capture temp files (cache/captures) to the system camera app. -->
124+ <provider
125+ android:name="androidx.core.content.FileProvider"
126+ android:authorities="${applicationId}.fileprovider"
127+ android:exported="false"
128+ android:grantUriPermissions="true">
129+ <meta-data
130+ android:name="android.support.FILE_PROVIDER_PATHS"
131+ android:resource="@xml/file_paths" />
132+ </provider>
25133 </application>
134+
26135</manifest>
app/src/main/java/soy/iko/opencode/CoreViewModel.ktdeleted−66
@@ -1,66 +0,0 @@
1−package soy.iko.opencode
2−
3−import android.app.Application
4−import android.content.Context
5−import androidx.core.content.edit
6−import androidx.lifecycle.AndroidViewModel
7−import androidx.lifecycle.viewModelScope
8−import kotlinx.coroutines.flow.distinctUntilChangedBy
9−import kotlinx.coroutines.launch
10−import soy.iko.opencode.core.Event
11−import soy.iko.opencode.core.OpencodeCore
12−
13−/**
14− * Lifecycle-scoped host for the [OpencodeCore] so a single core (and its SSE
15− * connection) survives configuration changes. It also persists the last server
16− * URL / username so reconnecting is one tap.
17− */
18−class CoreViewModel(app: Application) : AndroidViewModel(app) {
19− private val prefs = app.getSharedPreferences("opencode", Context.MODE_PRIVATE)
20−
21− val core = OpencodeCore(viewModelScope)
22−
23− init {
24− prefs.getString(KEY_URL, null)?.let { core.dispatch(Event.ServerUrlChanged(it)) }
25− prefs.getString(KEY_USER, null)?.let { core.dispatch(Event.UsernameChanged(it)) }
26− // The password is stored in plain text in SharedPreferences — a deliberate
27− // convenience trade-off for a local dev tool so reconnecting is one tap.
28− prefs.getString(KEY_PASSWORD, null)?.let { core.dispatch(Event.PasswordChanged(it)) }
29−
30− // On a fresh process after a configuration change, resume the SSE stream if
31− // the core is already connected (no-op otherwise — it guards on `connected`).
32− core.resumeStreamingIfConnected()
33−
34− // Returning users shouldn't have to tap Connect: if a URL was restored and we
35− // aren't already connected, kick off the connection attempt on launch. The core
36− // handles an empty/invalid URL, so this only fires when there's something to try.
37− core.view.value.let { restored ->
38− if (restored.serverUrl.isNotEmpty() && !restored.connected) {
39− core.dispatch(Event.Connect)
40− }
41− }
42−
43− // Persist connection details once a connection succeeds.
44− viewModelScope.launch {
45− core.view.distinctUntilChangedBy { it.connected to it.serverUrl }.collect { state ->
46− if (state.connected) {
47− prefs.edit {
48− putString(KEY_URL, state.serverUrl)
49− putString(KEY_USER, state.username)
50− putString(KEY_PASSWORD, state.password)
51− }
52− }
53− }
54− }
55− }
56−
57− override fun onCleared() {
58− core.shutdown()
59− }
60−
61− private companion object {
62− const val KEY_URL = "serverUrl"
63− const val KEY_USER = "username"
64− const val KEY_PASSWORD = "password"
65− }
66−}
app/src/main/java/soy/iko/opencode/MainActivity.kt+473−15
@@ -1,31 +1,489 @@
11package soy.iko.opencode
22
3+import android.Manifest
4+import android.content.Intent
5+import android.content.pm.PackageManager
6+import android.net.Uri
7+import android.os.Build
38import android.os.Bundle
4−import androidx.activity.ComponentActivity
5−import androidx.activity.SystemBarStyle
9+import android.view.WindowManager
10+import androidx.core.content.IntentCompat
11+import soy.iko.opencode.data.network.NetworkConfig
12+import soy.iko.opencode.notification.NotificationActionReceiver
613import androidx.activity.compose.setContent
714import androidx.activity.enableEdgeToEdge
15+import androidx.activity.result.contract.ActivityResultContracts
16+import androidx.compose.foundation.isSystemInDarkTheme
17+import androidx.compose.material3.AlertDialog
18+import androidx.compose.material3.Text
19+import androidx.compose.material3.TextButton
20+import androidx.compose.runtime.LaunchedEffect
821import androidx.compose.runtime.getValue
22+import androidx.compose.runtime.mutableStateOf
23+import androidx.compose.runtime.remember
24+import androidx.compose.runtime.setValue
25+import androidx.compose.ui.res.stringResource
26+import kotlinx.coroutines.Dispatchers
27+import kotlinx.coroutines.flow.combine
28+import kotlinx.coroutines.launch
29+import kotlinx.coroutines.withContext
30+import androidx.core.content.ContextCompat
31+import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
32+import androidx.fragment.app.FragmentActivity
933import androidx.lifecycle.compose.collectAsStateWithLifecycle
10−import androidx.lifecycle.viewmodel.compose.viewModel
11−import soy.iko.opencode.ui.OpencodeApp
34+import androidx.lifecycle.lifecycleScope
35+import soy.iko.opencode.data.repo.SettingsStore
36+import soy.iko.opencode.data.repo.ThemeMode
37+import soy.iko.opencode.ui.AppLockGate
38+import soy.iko.opencode.ui.OpencodeApp as OpencodeAppUi
1239import soy.iko.opencode.ui.theme.OpencodeTheme
1340
14−class MainActivity : ComponentActivity() {
41+// FragmentActivity (not ComponentActivity) so androidx.biometric BiometricPrompt can attach
42+// for the app-lock gate; FragmentActivity extends ComponentActivity, so Compose/edge-to-edge
43+// /activity-result APIs are unaffected.
44+class MainActivity : FragmentActivity() {
45+
46+ private val requestNotificationPermission = registerForActivityResult(
47+ ActivityResultContracts.RequestPermission(),
48+ ) { /* Result is best-effort; notifications are silently skipped if denied. */ }
49+
50+ private var shareIntentHandled = false
51+ private var openSessionHandled = false
52+ private var newSessionHandled = false
53+ private var openFileHandled = false
54+ // Set when a deep link was present but failed validation (malformed id/path). Surfaced
55+ // as a toast from onResume so the user knows the link was rejected, not silently ignored.
56+ // On a warm start (onNewIntent while already resumed) the toast is shown immediately from
57+ // handleIntent, since onResume is NOT re-called on an already-resumed activity — without
58+ // that the toast would stay pending until the next pause/resume cycle (e.g. user
59+ // backgrounds and returns), making the rejected link look silently ignored.
60+ private var showInvalidLinkToast = false
61+ // True between onResume and onPause. Used by handleIntent to decide whether to fire the
62+ // invalid-link toast immediately (warm start) or defer it to onResume (cold start, where
63+ // a toast from onCreate could flash before the first frame is drawn).
64+ private var isResumed = false
65+ // Cold-start prompts (crash report, notification rationale) rendered as Compose M3
66+ // dialogs. Hoisted as Activity-level mutableState so the non-composable trigger logic
67+ // (onCreate/onResume) can flip them and the Compose tree (inside OpencodeTheme) reads
68+ // them and renders an M3 AlertDialog that picks up the app palette. The triggers
69+ // themselves are idempotent per instance (crashes are acknowledged on first fire;
70+ // notificationPermissionRequested is restored from saved state), so a plain
71+ // mutableStateOf (not saveable) preserves the original dismissal semantics across
72+ // config-change recreation: the prompt simply doesn't re-fire after a rotate.
73+ private var showCrashPrompt by mutableStateOf(false)
74+ private var showNotifRationale by mutableStateOf(false)
75+ // Track whether the notification-permission prompt has been shown for this Activity
76+ // instance. onCreate only requests when savedInstanceState == null (a fresh process),
77+ // but an Activity recreated by a config change NOT listed in the manifest's configChanges
78+ // (e.g. a locale change) has a non-null savedInstanceState and would otherwise skip the
79+ // prompt forever. onResume re-requests once per instance when still ungranted so the user
80+ // is never silently left without notifications after such a recreation.
81+ private var notificationPermissionRequested = false
82+
1583 override fun onCreate(savedInstanceState: Bundle?) {
16− enableEdgeToEdge(
17− statusBarStyle = SystemBarStyle.auto(
18− lightScrim = 0xFF0F1115.toInt(),
19− darkScrim = 0xFF0F1115.toInt(),
20− ),
21− )
84+ val splash = installSplashScreen()
2285 super.onCreate(savedInstanceState)
86+ enableEdgeToEdge()
87+ savedInstanceState?.let {
88+ shareIntentHandled = it.getBoolean(KEY_SHARE_HANDLED, false)
89+ openSessionHandled = it.getBoolean(KEY_OPEN_SESSION_HANDLED, false)
90+ newSessionHandled = it.getBoolean(KEY_NEW_SESSION_HANDLED, false)
91+ openFileHandled = it.getBoolean(KEY_OPEN_FILE_HANDLED, false)
92+ }
93+ val container = (application as OpencodeApp).container
94+ handleIntent(intent)
95+ if (savedInstanceState == null) {
96+ maybeRequestNotificationPermission()
97+ } else {
98+ // Restored instance: the permission may have been requested pre-recreation, but
99+ // we can't know the result from here. onResume will re-check and request if still
100+ // ungranted (once per Activity instance).
101+ notificationPermissionRequested = savedInstanceState.getBoolean(KEY_NOTIF_PERM_REQUESTED, false)
102+ }
103+ // Hold the splash until the persisted theme and app-lock settings load, so we never
104+ // paint a frame with the defaults (dynamicColor=false / SYSTEM, app lock off) that then
105+ // snap to the user's real choice — including a brief unlocked, un-FLAG_SECURE'd frame
106+ // before app lock resolves. Mirrors the SettingsScreen's null-gate.
107+ var settingsLoaded = false
108+ splash.setKeepOnScreenCondition { !settingsLoaded }
109+ // Custom splash exit: a quick fade-out instead of the system default's slide, so the
110+ // hand-off to Compose reads as a branded dissolve. The listener must call
111+ // splashScreenViewProvider.remove() to release the splash view; otherwise the system
112+ // keeps it on screen for its default duration. Reduced-motion is respected via the
113+ // animator's duration scale (the system zeroes it under reduced motion).
114+ splash.setOnExitAnimationListener { splashScreenViewProvider ->
115+ val view = splashScreenViewProvider.view
116+ val fade = android.view.animation.AlphaAnimation(1f, 0f).apply {
117+ duration = 180
118+ fillAfter = true
119+ }
120+ fade.setAnimationListener(object : android.view.animation.Animation.AnimationListener {
121+ override fun onAnimationStart(animation: android.view.animation.Animation?) {}
122+ override fun onAnimationEnd(animation: android.view.animation.Animation?) {
123+ splashScreenViewProvider.remove()
124+ }
125+ override fun onAnimationRepeat(animation: android.view.animation.Animation?) {}
126+ })
127+ view.startAnimation(fade)
128+ }
23129 setContent {
24− OpencodeTheme {
25− val vm: CoreViewModel = viewModel()
26− val state by vm.core.view.collectAsStateWithLifecycle()
27− OpencodeApp(state = state, dispatch = vm.core::dispatch)
130+ val theme by remember(container) {
131+ container.settingsStore.themeMode
132+ .combine(container.settingsStore.dynamicColor, ::Pair)
133+ }.collectAsStateWithLifecycle(initialValue = null)
134+ val appLock by container.settingsStore.appLock.collectAsStateWithLifecycle(initialValue = null)
135+ val appLockReLockSeconds by container.settingsStore.appLockReLockSeconds
136+ .collectAsStateWithLifecycle(initialValue = SettingsStore.DEFAULT_APP_LOCK_RELOCK_SECONDS)
137+ LaunchedEffect(theme, appLock) { if (theme != null && appLock != null) settingsLoaded = true }
138+ // Hide the app's content from screenshots / the recents thumbnail while app lock is
139+ // enabled — and while its value is still unknown (null) at cold start, so a protected
140+ // launch can't flash into the recents thumbnail before the setting resolves.
141+ LaunchedEffect(appLock) {
142+ if (appLock != false) window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
143+ else window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
144+ }
145+ val themePair = theme
146+ val locked = appLock
147+ if (themePair != null && locked != null) {
148+ val (themeMode, dynamicColor) = themePair
149+ val amoled = themeMode == ThemeMode.AMOLED
150+ val dark = when (themeMode) {
151+ ThemeMode.SYSTEM -> isSystemInDarkTheme()
152+ ThemeMode.LIGHT -> false
153+ ThemeMode.DARK -> true
154+ ThemeMode.AMOLED -> true
155+ }
156+ OpencodeTheme(darkTheme = dark, dynamicColor = dynamicColor, amoled = amoled) {
157+ AppLockGate(enabled = locked, reLockDelaySeconds = appLockReLockSeconds) {
158+ OpencodeAppUi(container = container)
159+ }
160+ // First-launch prompts as Compose M3 dialogs (siblings of AppLockGate so they
161+ // still surface over the lock screen, matching the prior framework-dialog
162+ // behavior) and inside OpencodeTheme so they use the app palette.
163+ if (showCrashPrompt) {
164+ AlertDialog(
165+ onDismissRequest = { showCrashPrompt = false },
166+ title = { Text(stringResource(R.string.crash_last_run_title)) },
167+ text = { Text(stringResource(R.string.crash_last_run_text)) },
168+ confirmButton = {
169+ TextButton(onClick = {
170+ showCrashPrompt = false
171+ container.requestDiagnostics()
172+ }) { Text(stringResource(R.string.crash_last_run_view)) }
173+ },
174+ dismissButton = {
175+ TextButton(onClick = { showCrashPrompt = false }) {
176+ Text(stringResource(R.string.crash_last_run_dismiss))
177+ }
178+ },
179+ )
180+ }
181+ if (showNotifRationale) {
182+ AlertDialog(
183+ // Mirrors the original setCancelable(false): not dismissible by
184+ // back/outside tap, only by the buttons below.
185+ onDismissRequest = { },
186+ title = { Text(stringResource(R.string.notif_rationale_title)) },
187+ text = { Text(stringResource(R.string.notif_rationale_text)) },
188+ confirmButton = {
189+ TextButton(onClick = {
190+ showNotifRationale = false
191+ runCatching { requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) }
192+ }) { Text(stringResource(R.string.notif_rationale_allow)) }
193+ },
194+ dismissButton = {
195+ TextButton(onClick = { showNotifRationale = false }) {
196+ Text(stringResource(R.string.notif_rationale_skip))
197+ }
198+ },
199+ )
200+ }
201+ }
202+ }
203+ }
204+ // Surface a "crashed last time" prompt if a crash report from a previous run
205+ // hasn't been acknowledged. Acknowledges immediately so it doesn't re-fire on
206+ // every rotation/resume; the user can always find reports in Settings → Diagnostics.
207+ maybeShowCrashPrompt()
208+ }
209+
210+ private fun maybeShowCrashPrompt() {
211+ // The check does a directory scan + per-file stat (listFiles + lastModified), which
212+ // is synchronous filesystem I/O. Run it off the main thread so the cold-start
213+ // critical path (before setContent's first frame) isn't blocked by up to ~20 stat
214+ // syscalls on slow storage. The prompt state flips on once the result arrives; the
215+ // splash screen has already been dismissed by then, so the dialog appears a frame
216+ // or two later than it would have synchronously — invisible to the user.
217+ val crashLogger = soy.iko.opencode.data.repo.CrashLogger.get(this)
218+ lifecycleScope.launch {
219+ val hasUnacknowledged = withContext(Dispatchers.IO) {
220+ crashLogger.hasUnacknowledgedCrash()
221+ }
222+ if (hasUnacknowledged) {
223+ crashLogger.acknowledgeCrashes()
224+ showCrashPrompt = true
28225 }
29226 }
30227 }
228+
229+ override fun onNewIntent(intent: Intent) {
230+ super.onNewIntent(intent)
231+ setIntent(intent)
232+ shareIntentHandled = false
233+ openSessionHandled = false
234+ newSessionHandled = false
235+ openFileHandled = false
236+ handleIntent(intent)
237+ }
238+
239+ // Track real foreground state so background notifications (permission requests,
240+ // completions, errors) still fire for a session the user "has open" but has walked
241+ // away from — the chat screen isn't disposed when the phone is locked, so
242+ // container.currentSession stays set and can't be used as a foreground signal.
243+ override fun onStart() {
244+ super.onStart()
245+ (application as OpencodeApp).container.setForeground(true)
246+ }
247+
248+ override fun onStop() {
249+ super.onStop()
250+ (application as OpencodeApp).container.setForeground(false)
251+ }
252+
253+ override fun onResume() {
254+ super.onResume()
255+ isResumed = true
256+ // Re-check on resume: an Activity recreated by a config change not in configChanges
257+ // (e.g. locale) has savedInstanceState != null, so onCreate skipped the request. This
258+ // ensures the user is prompted once per Activity instance when still ungranted rather
259+ // than silently losing all notifications.
260+ maybeRequestNotificationPermission()
261+ // Surface a rejected deep link once, so a malformed opencode:// link doesn't appear
262+ // to be silently ignored. Cleared after firing so a rotation doesn't re-toast. This
263+ // covers the cold-start path (handleIntent ran from onCreate, before the activity was
264+ // resumed, so it deferred the toast here). Warm-start rejections fire immediately from
265+ // handleIntent and never set this flag.
266+ if (showInvalidLinkToast) {
267+ showInvalidLinkToast = false
268+ showInvalidLinkToastNow()
269+ }
270+ }
271+
272+ override fun onPause() {
273+ super.onPause()
274+ isResumed = false
275+ }
276+
277+ /** Fire the invalid-deep-link toast. Extracted so the cold-start (onResume) and warm-start
278+ * (handleIntent) paths share a single implementation. */
279+ private fun showInvalidLinkToastNow() {
280+ android.widget.Toast.makeText(this, R.string.deep_link_invalid, android.widget.Toast.LENGTH_SHORT).show()
281+ }
282+
283+ /** Capture text shared from another app so it can be prefilled into a session draft,
284+ * and session ids from notification taps / deep links so we can open them. */
285+ private fun handleIntent(intent: Intent?) {
286+ val container = (application as OpencodeApp).container
287+ val action = intent?.action
288+ // "New session" from a launcher shortcut or the quick-settings tile. Guarded so a
289+ // process-death restore (which re-delivers the intent) doesn't spawn a second session.
290+ if (action == ACTION_NEW_SESSION && !newSessionHandled) {
291+ container.requestNewSession()
292+ newSessionHandled = true
293+ }
294+ if ((action == Intent.ACTION_SEND || action == Intent.ACTION_SEND_MULTIPLE) && !shareIntentHandled) {
295+ val type = intent.type.orEmpty()
296+ if (action == Intent.ACTION_SEND && type.startsWith("image/")) {
297+ // A single shared image → stage as an attachment in the next opened session.
298+ val uri = IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
299+ if (uri != null) {
300+ container.setPendingSharedMedia(listOf(uri.toString()))
301+ shareIntentHandled = true
302+ }
303+ } else if (action == Intent.ACTION_SEND_MULTIPLE && type.startsWith("image/")) {
304+ val uris = IntentCompat.getParcelableArrayListExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
305+ ?.take(NetworkConfig.maxAttachments)
306+ .orEmpty()
307+ if (uris.isNotEmpty()) {
308+ container.setPendingSharedMedia(uris.map { it.toString() })
309+ shareIntentHandled = true
310+ }
311+ } else if (action == Intent.ACTION_SEND) {
312+ // A non-image file (e.g. a PDF or text file shared from another app): stage it
313+ // as an attachment too, so the user can send it to the agent. The attachment
314+ // pipeline handles non-image mimes with a generic file icon. Falls back to
315+ // EXTRA_TEXT when no stream is present (a plain text share).
316+ val uri = IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
317+ if (uri != null) {
318+ container.setPendingSharedMedia(listOf(uri.toString()))
319+ shareIntentHandled = true
320+ } else {
321+ val text = intent.getStringExtra(Intent.EXTRA_TEXT)
322+ ?.takeIf { it.isNotBlank() }
323+ ?.take(10_000) // cap to prevent unbounded memory usage from malicious shares
324+ if (text != null) {
325+ container.setPendingShare(text)
326+ shareIntentHandled = true
327+ }
328+ }
329+ } else {
330+ val text = intent.getStringExtra(Intent.EXTRA_TEXT)
331+ ?.takeIf { it.isNotBlank() }
332+ ?.take(10_000) // cap to prevent unbounded memory usage from malicious shares
333+ if (text != null) {
334+ container.setPendingShare(text)
335+ shareIntentHandled = true
336+ }
337+ }
338+ }
339+ // Deep links under the opencode:// scheme. The manifest exposes several hosts:
340+ // session/{id} → open (and switch to) that conversation
341+ // file/{path} → open the file viewer at that workspace path
342+ // new → start a fresh session
343+ // The file path is percent-encoded by the link source; decode it here and validate the
344+ // segment to keep path-traversal out of the REST path (mirroring the session guard).
345+ val data = intent?.takeIf { it.action == Intent.ACTION_VIEW }?.data
346+ when (data?.host) {
347+ "session" -> {
348+ val seg = data.lastPathSegment
349+ if (!openSessionHandled) {
350+ seg?.takeIf { it.isNotBlank() && it.matches(VALID_SESSION_ID) }?.let {
351+ val profileId = intent.getStringExtra(NotificationActionReceiver.EXTRA_PROFILE_ID)
352+ ?.takeIf { it.isNotBlank() }
353+ container.requestOpenSession(it, profileId)
354+ openSessionHandled = true
355+ } ?: run {
356+ // The link carried a session host but no valid id (or a blank/traversal
357+ // attempt). Surface a toast so the user knows the link was rejected,
358+ // rather than silently no-op'ing (which reads as "the app did nothing").
359+ // On a warm start (already resumed) fire immediately; on a cold start
360+ // defer to onResume so the toast doesn't race the first frame.
361+ if (!seg.isNullOrBlank()) {
362+ if (isResumed) showInvalidLinkToastNow() else showInvalidLinkToast = true
363+ }
364+ // Mark the link handled even on rejection, so a process-death-and-restore
365+ // (which re-delivers the same intent) doesn't re-fire the toast on every
366+ // restore — the rejection is final, not a transient state to retry.
367+ openSessionHandled = true
368+ }
369+ }
370+ }
371+ "file" -> {
372+ // Reconstruct the path from the URI. `file` is the URI *host* (authority), so
373+ // data.path is the path AFTER the authority — e.g. `opencode://file/foo/bar.txt`
374+ // yields data.path = "/foo/bar.txt", which begins with a leading slash that the
375+ // server's file endpoints don't expect (the in-app browser uses server-returned
376+ // FileNode.path values with no leading slash, e.g. "foo/bar.txt"). Strip the
377+ // single leading slash, then percent-decode and validate against traversal/control
378+ // characters. (Using pathSegments would also work but over-decodes `+` and other
379+ // characters that aren't percent-encoded in real workspace paths.)
380+ val rawPath = data.path?.let { p ->
381+ val stripped = if (p.startsWith("/")) p.substring(1) else p
382+ android.net.Uri.decode(stripped)
383+ }
384+ if (!openFileHandled) {
385+ rawPath?.takeIf { isValidFilePath(it) }
386+ ?.let { container.requestOpenFile(it); openFileHandled = true } ?: run {
387+ if (!rawPath.isNullOrBlank()) {
388+ // Same warm-vs-cold logic as the session branch above.
389+ if (isResumed) showInvalidLinkToastNow() else showInvalidLinkToast = true
390+ }
391+ // Mark handled on rejection too, mirroring the session branch —
392+ // otherwise a process-death restore re-fires the invalid-link toast.
393+ openFileHandled = true
394+ }
395+ }
396+ }
397+ "new" -> {
398+ if (!newSessionHandled) {
399+ container.requestNewSession()
400+ newSessionHandled = true
401+ }
402+ }
403+ }
404+ // Fall back to the EXTRA_SESSION_ID extra (notifications / widget) when no deep link
405+ // was present at all. A deep link that matched a host but failed validation (e.g. a
406+ // malformed session id) does NOT fall through to the extra: the intent's primary
407+ // routing signal was the URI, and the extra may carry a different (valid) id that
408+ // would silently open the wrong session. Only when data?.host is null (a pure widget
409+ // / notification intent with no URI) does the extra carry the routing intent.
410+ if (data?.host == null && !openSessionHandled) {
411+ intent?.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() && it.matches(VALID_SESSION_ID) }?.let {
412+ val profileId = intent.getStringExtra(NotificationActionReceiver.EXTRA_PROFILE_ID)
413+ ?.takeIf { it.isNotBlank() }
414+ container.requestOpenSession(it, profileId)
415+ openSessionHandled = true
416+ }
417+ }
418+ }
419+
420+ companion object {
421+ /** Intent extra carrying a session id to open (notifications / deep links / widget). */
422+ const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
423+
424+ /** Action for the "New session" launcher shortcut and quick-settings tile. */
425+ const val ACTION_NEW_SESSION = "soy.iko.opencode.action.NEW_SESSION"
426+
427+ /** Session ids are alphanumeric with dashes/underscores. Reject path traversal. */
428+ private val VALID_SESSION_ID = Regex("[A-Za-z0-9_-]+")
429+
430+ /** A file path must not carry traversal segments or control characters. Looser than a
431+ * positive allowlist: real workspace paths commonly contain `~`, `:`, `+`, `=`, `,`,
432+ * parentheses, and non-ASCII unicode, all of which a tight `[A-Za-z0-9_\\-./ ]+`
433+ * regex would reject. The server validates the path against the workspace root, so
434+ * here we only reject what's unambiguously hostile (traversal) or non-printable. */
435+ private fun isValidFilePath(path: String): Boolean {
436+ if (path.isBlank()) return false
437+ if (path.contains("..")) return false
438+ if (path.any { it.isISOControl() }) return false
439+ return true
440+ }
441+
442+ private const val KEY_SHARE_HANDLED = "shareIntentHandled"
443+ private const val KEY_OPEN_SESSION_HANDLED = "openSessionHandled"
444+ private const val KEY_NEW_SESSION_HANDLED = "newSessionHandled"
445+ private const val KEY_OPEN_FILE_HANDLED = "openFileHandled"
446+ private const val KEY_NOTIF_PERM_REQUESTED = "notificationPermissionRequested"
447+ }
448+
449+ override fun onSaveInstanceState(outState: Bundle) {
450+ super.onSaveInstanceState(outState)
451+ val container = (application as OpencodeApp).container
452+ // Persist each intent-handled flag as "consumed", not merely "dispatched". The pending
453+ // signals live only in the in-memory AppContainer, which is recreated EMPTY on process
454+ // death. Persisting a plain "dispatched = true" would, after a kill *before* the UI
455+ // consumed the signal, restore the flag as true so the fresh container never receives it —
456+ // the deep-linked / new / shared session would silently never open. So AND each flag with
457+ // "the container no longer holds the signal": still-pending at save time → persist false so
458+ // the re-delivered intent re-dispatches it into the new container on restore; already
459+ // consumed → persist true so we don't re-open it unexpectedly on every later restore.
460+ val openConsumed = container.pendingOpenSession.value == null
461+ val newConsumed = container.pendingNewSession.value == 0
462+ val shareConsumed = container.pendingShare.value == null && container.pendingSharedMedia.value.isEmpty()
463+ val fileConsumed = container.pendingOpenFile.value == null
464+ outState.putBoolean(KEY_SHARE_HANDLED, shareIntentHandled && shareConsumed)
465+ outState.putBoolean(KEY_OPEN_SESSION_HANDLED, openSessionHandled && openConsumed)
466+ outState.putBoolean(KEY_NEW_SESSION_HANDLED, newSessionHandled && newConsumed)
467+ outState.putBoolean(KEY_OPEN_FILE_HANDLED, openFileHandled && fileConsumed)
468+ outState.putBoolean(KEY_NOTIF_PERM_REQUESTED, notificationPermissionRequested)
469+ }
470+
471+ /** Ask for POST_NOTIFICATIONS on Android 13+ so run/completion notifications show.
472+ * Idempotent per Activity instance via [notificationPermissionRequested].
473+ * Shows a rationale dialog first (explaining what the user gains) before the system
474+ * permission prompt, so a user who denies understands what they're missing. */
475+ private fun maybeRequestNotificationPermission() {
476+ if (notificationPermissionRequested) return
477+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
478+ val granted = ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
479+ PackageManager.PERMISSION_GRANTED
480+ if (!granted) {
481+ notificationPermissionRequested = true
482+ showNotificationRationaleDialog()
483+ }
484+ }
485+
486+ private fun showNotificationRationaleDialog() {
487+ showNotifRationale = true
488+ }
31489}
app/src/main/java/soy/iko/opencode/OpencodeApp.ktnew file+130
@@ -0,0 +1,130 @@
1+package soy.iko.opencode
2+
3+import android.app.Application
4+import android.content.Context
5+import coil3.ImageLoader
6+import coil3.SingletonImageLoader
7+import coil3.disk.DiskCache
8+import coil3.memory.MemoryCache
9+import coil3.network.okhttp.OkHttpNetworkFetcherFactory
10+import okhttp3.Call
11+import okhttp3.CertificatePinner
12+import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
13+import okhttp3.OkHttpClient
14+import okhttp3.Request
15+import okio.Path.Companion.toOkioPath
16+import soy.iko.opencode.data.model.ServerProfile
17+import soy.iko.opencode.data.network.CertPinUtil
18+import soy.iko.opencode.data.repo.CrashLogger
19+import soy.iko.opencode.di.AppContainer
20+import java.util.concurrent.atomic.AtomicBoolean
21+
22+class OpencodeApp : Application(), SingletonImageLoader.Factory {
23+ lateinit var container: AppContainer
24+ private set
25+
26+ private val shutdownCalled = AtomicBoolean(false)
27+
28+ override fun onCreate() {
29+ super.onCreate()
30+ CrashLogger.get(this).install()
31+ container = AppContainer(this)
32+ // onTerminate() is only called in emulated process environments, not on real
33+ // devices. Register a JVM shutdown hook so cleanup (network callback unregister,
34+ // scope cancellation, connection close) runs when the process is actually killed.
35+ Runtime.getRuntime().addShutdownHook(Thread {
36+ shutdown()
37+ })
38+ }
39+
40+ // Process-wide Coil singleton. The defaults are sane; the explicit factory adds a
41+ // disk cache so scrolling back to an image-bearing message doesn't re-fetch it over
42+ // the (auth-protected) network every time. Data-URI (base64) images skip the disk
43+ // cache automatically; only resolved HTTP/S URLs are cached, keyed by URL.
44+ //
45+ // Route fetches through a Call.Factory that applies the SAME per-profile certificate
46+ // pinning as the REST/SSE client (see HttpClientFactory). Server-hosted images carry the
47+ // server's HTTP Basic auth header (see RemoteImage), so without this they would travel over
48+ // an UNPINNED TLS connection, silently defeating the pin the user opted into for that host.
49+ override fun newImageLoader(context: Context): ImageLoader = ImageLoader.Builder(context)
50+ .memoryCache {
51+ MemoryCache.Builder()
52+ .maxSizePercent(context, 0.25)
53+ .build()
54+ }
55+ .diskCache {
56+ DiskCache.Builder()
57+ .directory(cacheDir.resolve("image_cache").toOkioPath())
58+ .maxSizeBytes(50L * 1024 * 1024)
59+ .build()
60+ }
61+ .components {
62+ // Route fetches through a Call.Factory that applies the SAME per-profile certificate
63+ // pinning as the REST/SSE client (see HttpClientFactory). Server-hosted images carry
64+ // the server's HTTP Basic auth header (see RemoteImage), so without this they would
65+ // travel over an UNPINNED TLS connection, silently defeating the pin the user opted
66+ // into for that host.
67+ add(OkHttpNetworkFetcherFactory(callFactory = { PinnedImageCallFactory(container) }))
68+ }
69+ .build()
70+
71+ override fun onTerminate() {
72+ super.onTerminate()
73+ shutdown()
74+ }
75+
76+ private fun shutdown() {
77+ // Guard against double shutdown — onTerminate and the shutdown hook can both
78+ // fire in emulated environments.
79+ if (!shutdownCalled.compareAndSet(false, true)) return
80+ container.shutdown()
81+ }
82+}
83+
84+/**
85+ * A Coil [Call.Factory] that pins image loads exactly like [HttpClientFactory] pins the
86+ * REST/SSE channel. The [ImageLoader] is a process singleton built once, but the active
87+ * [ServerProfile] — and therefore its pins — changes when the user switches servers, so the
88+ * certificate pinner is resolved *per request* from [AppContainer.activeConnection] rather than
89+ * baked in at build time. This fails closed: a profile that configures pins always gets a pinned
90+ * connection for its images (an unparseable pinned host throws, matching HttpClientFactory), so
91+ * image loads for a pinned host are never silently downgraded to an unpinned channel — and a
92+ * later server switch is picked up immediately instead of retaining the first profile's pins.
93+ */
94+private class PinnedImageCallFactory(private val container: AppContainer) : Call.Factory {
95+ // A single shared base client; each derived pinned client reuses this one's connection pool
96+ // and dispatcher via newBuilder(), so per-profile pinning doesn't spin up parallel resources.
97+ private val base = OkHttpClient()
98+
99+ // Cache the last derived client keyed by (host, pins) so we don't rebuild it on every request;
100+ // a server switch changes the signature and triggers a rebuild. Guarded because Coil invokes
101+ // newCall from arbitrary background threads, possibly concurrently.
102+ private val lock = Any()
103+ private var cachedSignature: String? = null
104+ private var cachedClient: OkHttpClient = base
105+
106+ override fun newCall(request: Request): Call =
107+ clientFor(container.activeConnection.value?.profile).newCall(request)
108+
109+ private fun clientFor(profile: ServerProfile?): OkHttpClient {
110+ val pins = CertPinUtil.parseCertPins(profile?.certPin)
111+ if (pins.isEmpty()) return base
112+ // effectiveBaseUrl upgrades http->https when pinning is on, so the pinned host matches the
113+ // https origin RemoteImage resolves image URLs against. Fail closed on an unparseable host
114+ // (never fall back to an unpinned client), mirroring HttpClientFactory.create().
115+ val effectiveUrl = CertPinUtil.effectiveBaseUrl(profile!!)
116+ val host = effectiveUrl.toHttpUrlOrNull()?.host
117+ ?: throw IllegalStateException(
118+ "Certificate pinning is configured but the server host could not be parsed from $effectiveUrl",
119+ )
120+ val signature = "$host\n${pins.sorted().joinToString(" ")}"
121+ synchronized(lock) {
122+ if (signature != cachedSignature) {
123+ val pinner = CertificatePinner.Builder().apply { pins.forEach { add(host, it) } }.build()
124+ cachedClient = base.newBuilder().certificatePinner(pinner).build()
125+ cachedSignature = signature
126+ }
127+ return cachedClient
128+ }
129+ }
130+}
app/src/main/java/soy/iko/opencode/core/HttpClient.ktdeleted−75
@@ -1,75 +0,0 @@
1−package soy.iko.opencode.core
2−
3−import kotlinx.coroutines.Dispatchers
4−import kotlinx.coroutines.suspendCancellableCoroutine
5−import kotlinx.coroutines.withContext
6−import okhttp3.Call
7−import okhttp3.Callback
8−import okhttp3.MediaType.Companion.toMediaType
9−import okhttp3.OkHttpClient
10−import okhttp3.Request
11−import okhttp3.RequestBody.Companion.toRequestBody
12−import okhttp3.Response
13−import java.io.IOException
14−import java.util.concurrent.TimeUnit
15−import kotlin.coroutines.resume
16−import kotlin.coroutines.resumeWithException
17−
18−/** A completed HTTP response (any status code — non-2xx is not an error here). */
19−data class HttpResp(val code: Int, val body: String)
20−
21−/**
22− * Thin OkHttp wrapper for the opencode REST calls. Transport failures surface as
23− * [Result.failure]; every actual HTTP response (including 4xx/5xx) is a success
24− * carrying its status code, matching the Rust core's `HttpResult` handling.
25− */
26−class HttpClient(
27− val okhttp: OkHttpClient = OkHttpClient.Builder()
28− .connectTimeout(15, TimeUnit.SECONDS)
29− .readTimeout(30, TimeUnit.SECONDS)
30− .build(),
31−) {
32− private val jsonMedia = "application/json".toMediaType()
33−
34− suspend fun get(url: String, auth: String?): Result<HttpResp> =
35− exec(url, auth) { get() }
36−
37− suspend fun postJson(url: String, body: String, auth: String?): Result<HttpResp> =
38− exec(url, auth) { post(body.toRequestBody(jsonMedia)) }
39−
40− suspend fun delete(url: String, auth: String?): Result<HttpResp> =
41− exec(url, auth) { delete() }
42−
43− // Build and run the request off the main thread. Building it — parsing [url]
44− // in particular — can throw (e.g. a URL with no scheme), so that happens
45− // inside [runCatching] too: a bad URL then surfaces as [Result.failure]
46− // instead of crashing the caller's coroutine.
47− private suspend fun exec(
48− url: String,
49− auth: String?,
50− method: Request.Builder.() -> Request.Builder,
51− ): Result<HttpResp> = withContext(Dispatchers.IO) {
52− runCatching {
53− val builder = Request.Builder().url(url).method()
54− builder.header("User-Agent", "opencode-android/1.0")
55− if (auth != null) builder.header("Authorization", auth)
56− await(okhttp.newCall(builder.build()))
57− }
58− }
59−
60− private suspend fun await(call: Call): HttpResp = suspendCancellableCoroutine { cont ->
61− cont.invokeOnCancellation { runCatching { call.cancel() } }
62− call.enqueue(object : Callback {
63− override fun onFailure(call: Call, e: IOException) {
64− if (cont.isActive) cont.resumeWithException(e)
65− }
66−
67− override fun onResponse(call: Call, response: Response) {
68− response.use {
69− val body = runCatching { it.body?.string() }.getOrNull().orEmpty()
70− if (cont.isActive) cont.resume(HttpResp(it.code, body))
71− }
72− }
73− })
74− }
75−}
app/src/main/java/soy/iko/opencode/core/OpencodeCore.ktdeleted−668
@@ -1,668 +0,0 @@
1−package soy.iko.opencode.core
2−
3−import kotlinx.coroutines.CoroutineScope
4−import kotlinx.coroutines.Job
5−import kotlinx.coroutines.delay
6−import kotlinx.coroutines.flow.MutableStateFlow
7−import kotlinx.coroutines.flow.StateFlow
8−import kotlinx.coroutines.flow.asStateFlow
9−import kotlinx.coroutines.launch
10−import java.util.Base64
11−
12−/**
13− * The application core: a Kotlin port of the Rust Crux core (`shared/`).
14− *
15− * It owns all state, turns [Event]s into state changes + effects, and exposes an
16− * observable [view]. Effects are executed here in the shell: REST calls through
17− * [HttpClient] and the long-lived `/event` stream through [SseClient]. Every
18− * server-sent event is fed back in and applied incrementally, so assistant
19− * replies stream in token-by-token.
20− *
21− * All state mutation happens on [scope]'s (main) dispatcher; only the blocking
22− * network I/O hops to a background thread inside the clients.
23− */
24−class OpencodeCore(
25− private val scope: CoroutineScope,
26− private val http: HttpClient = HttpClient(),
27−) {
28− // ── Model ────────────────────────────────────────────────────────────────
29− private var serverUrl = ""
30− private var username = ""
31− private var password = ""
32− private var authRequired = false
33− private var connected = false
34− private var loading = false
35− private var error: String? = null
36− private var screen = Screen.Connect
37− private val sessions = mutableListOf<SessionView>()
38− private var currentSessionId: String? = null
39− private var currentSessionTitle = ""
40− private val messages = mutableListOf<MsgState>()
41− private var draft = ""
42− private var generating = false
43− private var sseConnected = true
44− private var localSeq = 0L
45−
46− private val _view = MutableStateFlow(snapshot())
47− val view: StateFlow<UiState> = _view.asStateFlow()
48−
49− private var sse: SseClient? = null
50− private var sseClosedIntentionally = false
51− private var reconnectAttempts = 0
52− private var reconnectJob: Job? = null
53−
54− // Guards against overlapping session refreshes: rapid taps launch multiple
55− // loadSessions coroutines and the slowest-to-return would otherwise win.
56− private var loadSessionsJob: Job? = null
57− // Tracks the in-flight createSession coroutine so navigating away can cancel it
58− // before it dispatches SelectSession into a screen the user already left.
59− private var createSessionJob: Job? = null
60− // Tracks the in-flight loadMessages coroutine. Rapidly switching sessions launches
61− // several; cancel the prior one so only the newest fetch runs (saves needless requests).
62− private var loadMessagesJob: Job? = null
63− // Tracks the in-flight probeHealth coroutine. Rapid double-taps on Connect would
64− // otherwise launch two concurrent health probes; cancel the prior one first.
65− private var connectJob: Job? = null
66− // Tracks the in-flight DeleteSession coroutine so navigating away can cancel it
67− // before its response handler mutates the sessions list / screen the user already left.
68− private var deleteSessionJob: Job? = null
69− // Tracks the in-flight sendMessage coroutine so navigating away or switching sessions
70− // can cancel it before its response handler touches stale message state.
71− private var sendMessageJob: Job? = null
72− // Tracks the in-flight abort coroutine (CancelGeneration) so rapid cancel taps don't
73− // fire multiple abort requests, and navigating away can cancel it.
74− private var abortJob: Job? = null
75− // Message IDs removed via SSE; a late MessageUpdated for one must not resurrect it.
76− private val removedMessageIds = mutableSetOf<String>()
77−
78− // ── Event entry point ──────────────────────────────────────────────────────
79− fun dispatch(event: Event) {
80− when (event) {
81− // Store the field verbatim while typing; normalizing here would fight
82− // the user's edits (e.g. eat the "://" as they type it). The URL is
83− // normalized once, on Connect.
84− // Clear any prior error as soon as the user edits a field, so a stale
85− // message (e.g. "Invalid credentials") doesn't linger while they correct it.
86− is Event.ServerUrlChanged -> { serverUrl = event.url; if (error != null) error = null; emit() }
87− is Event.UsernameChanged -> { username = event.value; if (error != null) error = null; emit() }
88− is Event.PasswordChanged -> { password = event.value; if (error != null) error = null; emit() }
89−
90− Event.Connect -> {
91− serverUrl = normalizeUrl(serverUrl)
92− if (serverUrl.isEmpty()) { error = "Enter a server URL"; emit(); return }
93− // The auth fields are showing but empty — sending the request now
94− // would just 401 again in a loop. Prompt for credentials instead.
95− if (authRequired && !hasCredentials()) { error = "Enter username and password"; emit(); return }
96− // Leave authRequired as-is; probeHealth sets it from the response (true on
97− // 401, false on success). Resetting it here just flickers the auth fields.
98− loading = true; error = null; emit()
99− connectJob?.cancel()
100− connectJob = scope.launch { probeHealth() }
101− }
102−
103− Event.LoadSessions -> {
104− if (!connected) { error = "Not connected"; emit(); return }
105− loading = true; emit()
106− loadSessionsJob?.cancel()
107− loadSessionsJob = scope.launch { loadSessions() }
108− }
109−
110− is Event.SelectSession -> {
111− // The session may have been deleted (e.g. via SSE) while the list was on
112− // screen; opening it would only 404 on loadMessages. Bail with an error.
113− val session = sessions.firstOrNull { it.id == event.id }
114− if (session == null) { error = "Session no longer exists"; emit(); return }
115− currentSessionTitle = session.title
116− currentSessionId = event.id
117− messages.clear()
118− removedMessageIds.clear()
119− screen = Screen.Chat
120− loading = true
121− generating = false
122− error = null
123− emit()
124− connectJob?.cancel()
125− abortJob?.cancel()
126− sendMessageJob?.cancel()
127− deleteSessionJob?.cancel()
128− loadMessagesJob?.cancel()
129− loadMessagesJob = scope.launch { loadMessages(event.id) }
130− }
131−
132− Event.CreateSession -> {
133− if (!connected) { error = "Not connected"; emit(); return }
134− loading = true; error = null; emit()
135− createSessionJob?.cancel()
136− createSessionJob = scope.launch { createSession() }
137− }
138−
139− is Event.DeleteSession -> {
140− if (!connected) { error = "Not connected"; emit(); return }
141− val id = event.id
142− // Delete on the server first; only drop it from the UI once that
143− // succeeds, so a failed request leaves the session recoverable.
144− loading = true; error = null; emit()
145− deleteSessionJob?.cancel()
146− deleteSessionJob = scope.launch {
147− http.delete("$serverUrl/session/$id", authHeader()).fold(
148− onSuccess = { resp ->
149− if (resp.code in 200..299) {
150− sessions.removeAll { it.id == id }
151− if (isCurrent(id)) {
152− currentSessionId = null
153− messages.clear()
154− screen = Screen.Sessions
155− }
156− } else {
157− error = "Failed to delete session"
158− }
159− loading = false
160− },
161− onFailure = {
162− error = "Failed to delete session"
163− loading = false
164− },
165− )
166− emit()
167− }
168− }
169−
170− is Event.DraftChanged -> { draft = event.text; emit() }
171−
172− is Event.SendMessage -> sendMessage(event.text)
173−
174− Event.CancelGeneration -> {
175− generating = false
176− messages.forEach { it.streaming = false }
177− emit()
178− val id = currentSessionId ?: return
179− // Debounce rapid cancel taps: cancel any in-flight abort before firing
180− // another so we don't stack duplicate abort requests.
181− abortJob?.cancel()
182− abortJob = scope.launch { http.postJson("$serverUrl/session/$id/abort", "{}", authHeader()) }
183− }
184−
185− Event.NavigateToSessions -> {
186− screen = Screen.Sessions
187− currentSessionId = null
188− messages.clear()
189− removedMessageIds.clear()
190− draft = ""
191− generating = false
192− // Clear any error from the chat (e.g. a failed send) so it doesn't
193− // linger on the sessions list after the user navigates back.
194− error = null
195− loading = true
196− emit()
197− connectJob?.cancel()
198− abortJob?.cancel()
199− createSessionJob?.cancel()
200− deleteSessionJob?.cancel()
201− sendMessageJob?.cancel()
202− loadMessagesJob?.cancel()
203− loadSessionsJob?.cancel()
204− loadSessionsJob = scope.launch { loadSessions() }
205− }
206−
207− Event.NavigateToConnect -> {
208− screen = Screen.Connect
209− connected = false
210− loading = false
211− generating = false
212− sessions.clear()
213− messages.clear()
214− removedMessageIds.clear()
215− currentSessionId = null
216− draft = ""
217− // Reset to the default so a late-firing close callback from the old
218− // SSE connection can't flash the reconnecting banner on the next connect.
219− sseConnected = true
220− connectJob?.cancel()
221− abortJob?.cancel()
222− createSessionJob?.cancel()
223− deleteSessionJob?.cancel()
224− sendMessageJob?.cancel()
225− loadMessagesJob?.cancel()
226− closeSse()
227− emit()
228− }
229−
230− Event.DismissError -> { error = null; emit() }
231− }
232− }
233−
234− /** Reconnect the stream on a fresh process (e.g. after rotation) if already connected. */
235− fun resumeStreamingIfConnected() {
236− if (connected && sse == null) startSse()
237− }
238−
239− fun shutdown() {
240− // Cancel every tracked coroutine before tearing down the stream so none can
241− // emit into a cancelled scope after the ViewModel is cleared.
242− connectJob?.cancel()
243− loadSessionsJob?.cancel()
244− createSessionJob?.cancel()
245− loadMessagesJob?.cancel()
246− deleteSessionJob?.cancel()
247− sendMessageJob?.cancel()
248− abortJob?.cancel()
249− closeSse()
250− }
251−
252− // ── Effects ────────────────────────────────────────────────────────────────
253− private fun sendMessage(raw: String) {
254− val text = raw.trim()
255− val session = currentSessionId ?: return
256− if (text.isEmpty()) return
257− // Debounce rapid double-taps on send: a send is already in flight, so a second
258− // tap would create a duplicate user message and fire a duplicate request.
259− if (generating) return
260− localSeq += 1
261− val user = MsgState("local-$localSeq", "user", System.currentTimeMillis()).apply {
262− status = MessageStatus.Pending
263− textParts["local"] = text
264− }
265− messages.add(user)
266− // Keep the draft text so we can restore it if the send fails; otherwise the
267− // user's message is lost and they'd have to retype it.
268− val savedDraft = draft
269− draft = ""
270− generating = true
271− error = null
272− emit()
273−
274− val body = Protocol.json.encodeToString(PromptBody.serializer(), PromptBody(listOf(TextInput(text = text))))
275− sendMessageJob?.cancel()
276− sendMessageJob = scope.launch {
277− val result = http.postJson("$serverUrl/session/$session/prompt_async", body, authHeader())
278− result.fold(
279− onSuccess = { resp ->
280− if (resp.code in 200..299) {
281− messages.firstOrNull { it.id == user.id }?.status = MessageStatus.Sent
282− } else {
283− messages.firstOrNull { it.id == user.id }?.status = MessageStatus.Failed
284− generating = false
285− messages.forEach { it.streaming = false }
286− draft = savedDraft
287− error = "Server returned status ${resp.code}"
288− }
289− },
290− onFailure = { e ->
291− messages.firstOrNull { it.id == user.id }?.status = MessageStatus.Failed
292− generating = false
293− messages.forEach { it.streaming = false }
294− draft = savedDraft
295− error = "Request failed: ${e.message}"
296− },
297− )
298− emit()
299− }
300− }
301−
302− private suspend fun probeHealth() {
303− val hasCreds = hasCredentials()
304− http.get("$serverUrl/global/health", authHeader()).fold(
305− onSuccess = { resp ->
306− when {
307− resp.code == 401 -> {
308− loading = false
309− // A prior connection may have succeeded (connected = true); a
310− // re-probe returning 401 (e.g. expired credentials) means we're
311− // no longer authenticated, so drop the connected flag.
312− connected = false
313− // Always reveal the auth fields so the user can see/edit
314− // their credentials — even when the ones supplied were wrong.
315− authRequired = true
316− if (hasCreds) error = "Invalid credentials"
317− emit()
318− }
319− resp.code in 200..299 -> {
320− connected = true
321− authRequired = false
322− error = null
323− screen = Screen.Sessions
324− loading = true
325− emit()
326− startSse()
327− // Route through loadSessionsJob so a prior in-flight loadSessions
328− // (e.g. from a rapid double-connect) is cancelled first.
329− loadSessionsJob?.cancel()
330− loadSessionsJob = scope.launch { loadSessions() }
331− }
332− else -> {
333− loading = false
334− connected = false
335− error = "Server returned status ${resp.code}"
336− emit()
337− }
338− }
339− },
340− onFailure = { e ->
341− loading = false
342− connected = false
343− error = "Connection failed: ${e.message}"
344− emit()
345− },
346− )
347− }
348−
349− private suspend fun loadSessions() {
350− http.get("$serverUrl/session", authHeader()).fold(
351− onSuccess = { resp ->
352− if (resp.code in 200..299) {
353− sessions.clear()
354− sessions.addAll(Protocol.parseSessions(resp.body).map { SessionView(it.id, displayTitle(it.title)) })
355− // Impose a stable alphabetical order so the list doesn't shuffle with
356− // whatever order the server happens to return sessions in.
357− sessions.sortBy { it.title.lowercase() }
358− } else {
359− error = "Server returned status ${resp.code}"
360− }
361− },
362− onFailure = { e -> error = "Request failed: ${e.message}" },
363− )
364− loading = false
365− emit()
366− }
367−
368− private suspend fun createSession() {
369− http.postJson("$serverUrl/session", "{}", authHeader()).fold(
370− onSuccess = { resp ->
371− val session = if (resp.code in 200..299) Protocol.parseSession(resp.body) else null
372− if (session != null) {
373− if (sessions.none { it.id == session.id }) {
374− sessions.add(0, SessionView(session.id, displayTitle(session.title)))
375− // Restore the alphabetical order immediately (matching loadSessions /
376− // SessionUpserted) so the new session doesn't sit out-of-order at the
377− // top until the SSE SessionUpserted event arrives to re-sort.
378− sessions.sortBy { it.title.lowercase() }
379− }
380− emit()
381− // SelectSession takes over the loading state (loads messages).
382− dispatch(Event.SelectSession(session.id))
383− return
384− }
385− error = "Server returned status ${resp.code}"
386− },
387− onFailure = { e -> error = "Request failed: ${e.message}" },
388− )
389− loading = false
390− emit()
391− }
392−
393− private suspend fun loadMessages(sessionId: String) {
394− http.get("$serverUrl/session/$sessionId/message", authHeader()).fold(
395− onSuccess = { resp ->
396− if (!isCurrent(sessionId)) { loading = false; return } // user navigated away while loading
397− loading = false
398− if (resp.code in 200..299) {
399− messages.clear()
400− removedMessageIds.clear()
401− messages.addAll(Protocol.parseMessages(resp.body).map(::messageFromWire))
402− generating = messages.any { it.streaming }
403− } else {
404− error = "Server returned status ${resp.code}"
405− }
406− emit()
407− },
408− onFailure = { e ->
409− if (!isCurrent(sessionId)) { loading = false; return }
410− loading = false
411− generating = false
412− error = "Request failed: ${e.message}"
413− emit()
414− },
415− )
416− }
417−
418− // ── SSE ─────────────────────────────────────────────────────────────────────
419− private fun startSse() {
420− closeSse()
421− sseClosedIntentionally = false
422− val client = SseClient(
423− url = "$serverUrl/event",
424− auth = authHeader(),
425− onEvent = { json -> scope.launch { handleServerEvent(json) } },
426− onStateChange = { open ->
427− scope.launch {
428− sseConnected = open
429− if (open) reconnectAttempts = 0 else scheduleReconnect()
430− emit()
431− }
432− },
433− )
434− sse = client
435− client.connect()
436− }
437−
438− private fun closeSse() {
439− sseClosedIntentionally = true
440− reconnectJob?.cancel()
441− reconnectJob = null
442− sse?.close()
443− sse = null
444− }
445−
446− private fun scheduleReconnect() {
447− if (sseClosedIntentionally || !connected) return
448− // Exponential backoff: 2s, 4s, 8s, 16s, capped at 30s. Reset to 0 once the
449− // stream reconnects (onStateChange(true)) so a healthy server starts fresh.
450− val delayMs = (2000L * (1L shl reconnectAttempts)).coerceAtMost(30_000L)
451− if (delayMs < 30_000L) reconnectAttempts += 1
452− // Cancel any pending reconnect so rapid network flapping can't stack multiple
453− // coroutines that each open a new SSE connection.
454− reconnectJob?.cancel()
455− reconnectJob = scope.launch {
456− delay(delayMs)
457− if (!sseClosedIntentionally && connected && screen != Screen.Connect) startSse()
458− }
459− }
460−
461− private fun handleServerEvent(json: String) {
462− val event = Protocol.parseEvent(json) ?: return
463− // Only re-render if the event actually changed our state; applyEvent returns
464− // false for events that don't apply (other session, unknown message, …).
465− if (applyEvent(event)) emit()
466− }
467−
468− /** Apply a server event to the model. Returns true iff any state actually changed. */
469− private fun applyEvent(event: Protocol.Event): Boolean {
470− when (event) {
471− is Protocol.Event.MessageUpdated -> {
472− val info = event.info
473− if (info.role != "assistant" || !isCurrent(info.sessionId)) return false
474− // A MessageRemoved may have arrived before this (late/out-of-order) update;
475− // don't re-create a message the server has already deleted.
476− if (info.id in removedMessageIds) return false
477− val streaming = info.time.completed == null && info.error == null
478− val existing = messages.firstOrNull { it.id == info.id }
479− if (existing != null) {
480− existing.streaming = streaming
481− if (existing.time == 0L) existing.time = info.time.created
482− } else {
483− messages.add(MsgState(info.id, "assistant", info.time.created).apply { this.streaming = streaming })
484− }
485− info.error?.let { error = it.message(); messages.forEach { m -> m.streaming = false }; generating = false }
486− return true
487− }
488−
489− is Protocol.Event.PartUpdated -> {
490− val part = event.part
491− val msg = messages.firstOrNull { it.id == part.messageId } ?: return false
492− // Synthetic text parts are intentionally skipped; report no change so we
493− // don't trigger a needless re-render/emit for them.
494− return when (part) {
495− is Protocol.Part.Text -> if (part.synthetic) {
496− false
497− } else {
498− msg.textParts[part.id] = pickText(part.text, event.delta, msg.textParts[part.id])
499− true
500− }
501− is Protocol.Part.Reasoning -> {
502− msg.reasoningParts[part.id] = pickText(part.text, event.delta, msg.reasoningParts[part.id])
503− true
504− }
505− is Protocol.Part.Tool -> {
506− msg.toolParts[part.id] = ToolView(part.tool, part.status, part.title)
507− true
508− }
509− }
510− }
511−
512− is Protocol.Event.PartRemoved -> {
513− val msg = messages.firstOrNull { it.id == event.messageId } ?: return false
514− msg.remove(event.partId)
515− return true
516− }
517−
518− is Protocol.Event.MessageRemoved -> {
519− // Remember the ID so a late MessageUpdated can't resurrect the message.
520− removedMessageIds.add(event.messageId)
521− return messages.removeAll { it.id == event.messageId }
522− }
523−
524− is Protocol.Event.SessionIdle -> {
525− if (!isCurrent(event.sessionId)) return false
526− generating = false
527− messages.forEach { it.streaming = false }
528− return true
529− }
530−
531− is Protocol.Event.SessionError -> {
532− val scoped = event.sessionId == null || isCurrent(event.sessionId)
533− if (!scoped) return false
534− event.error?.let { error = it.message() }
535− generating = false
536− messages.forEach { it.streaming = false }
537− return true
538− }
539−
540− is Protocol.Event.SessionUpserted -> {
541− val title = displayTitle(event.info.title)
542− val existing = sessions.firstOrNull { it.id == event.info.id }
543− if (existing != null) {
544− sessions[sessions.indexOf(existing)] = SessionView(event.info.id, title)
545− } else {
546− sessions.add(0, SessionView(event.info.id, title))
547− }
548− // Keep the list alphabetically sorted (matching loadSessions) so an
549− // upserted session doesn't jump to the top and break the ordering.
550− sessions.sortBy { it.title.lowercase() }
551− if (isCurrent(event.info.id)) currentSessionTitle = title
552− return true
553− }
554−
555− is Protocol.Event.SessionDeleted -> {
556− val id = event.sessionId ?: return false
557− val removed = sessions.removeAll { it.id == id }
558− if (isCurrent(id)) {
559− currentSessionId = null
560− messages.clear()
561− screen = Screen.Sessions
562− return true
563− }
564− return removed
565− }
566−
567− Protocol.Event.Other -> return false
568− }
569− }
570−
571− // ── View / helpers ──────────────────────────────────────────────────────────
572− private fun snapshot() = UiState(
573− screen = screen,
574− serverUrl = serverUrl,
575− username = username,
576− password = password,
577− authRequired = authRequired,
578− connected = connected,
579− loading = loading,
580− error = error,
581− sessions = sessions.toList(),
582− currentSessionId = currentSessionId,
583− currentSessionTitle = currentSessionTitle,
584− messages = messages.map { it.toView() },
585− draft = draft,
586− generating = generating,
587− sseConnected = sseConnected,
588− )
589−
590− private fun emit() { _view.value = snapshot() }
591−
592− private fun isCurrent(sessionId: String?) = sessionId != null && currentSessionId == sessionId
593− private fun hasCredentials() = username.isNotEmpty() || password.isNotEmpty()
594−
595− private fun authHeader(): String? {
596− if (!hasCredentials()) return null
597− val encoded = Base64.getEncoder().encodeToString("$username:$password".toByteArray())
598− return "Basic $encoded"
599− }
600−}
601−
602−// ── Internal message state (parts tracked by id for streaming) ─────────────────
603−private class MsgState(val id: String, val role: String, var time: Long) {
604− var status = MessageStatus.Sent
605− var streaming = false
606− val textParts = LinkedHashMap<String, String>()
607− val reasoningParts = LinkedHashMap<String, String>()
608− val toolParts = LinkedHashMap<String, ToolView>()
609−
610− fun remove(partId: String) {
611− textParts.remove(partId)
612− reasoningParts.remove(partId)
613− toolParts.remove(partId)
614− }
615−
616− fun toView(): MessageView {
617− val reasoning = reasoningParts.values.joinToString("")
618− return MessageView(
619− id = id,
620− role = role,
621− text = textParts.values.joinToString(""),
622− reasoning = reasoning.ifEmpty { null },
623− tools = toolParts.values.toList(),
624− time = time,
625− status = status,
626− streaming = streaming,
627− )
628− }
629−}
630−
631−private fun messageFromWire(env: Protocol.WireMessageEnvelope): MsgState {
632− val info = env.info
633− val msg = MsgState(info.id, info.role, info.time.created)
634− msg.streaming = info.role == "assistant" && info.time.completed == null && info.error == null
635− for (raw in env.parts) {
636− val obj = (raw as? kotlinx.serialization.json.JsonObject) ?: continue
637− when (val part = Protocol.parsePart(obj)) {
638− is Protocol.Part.Text -> if (!part.synthetic) msg.textParts[part.id] = part.text
639− is Protocol.Part.Reasoning -> msg.reasoningParts[part.id] = part.text
640− is Protocol.Part.Tool -> msg.toolParts[part.id] = ToolView(part.tool, part.status, part.title)
641− null -> Unit
642− }
643− }
644− return msg
645−}
646−
647−private fun pickText(text: String, delta: String?, prior: String?): String =
648− if (text.isNotEmpty()) text else (prior.orEmpty() + (delta ?: ""))
649−
650−private fun normalizeUrl(url: String): String {
651− val trimmed = url.trim().trimEnd('/')
652− // Default to http:// when the user didn't type a scheme (e.g. "192.168.1.10:4096").
653− // Without a scheme OkHttp's URL parser throws and the connect attempt crashes.
654− return when {
655− trimmed.isEmpty() -> ""
656− "://" in trimmed -> trimmed
657− else -> "http://$trimmed"
658− }
659−}
660−
661−private fun displayTitle(title: String): String = title.trim().ifEmpty { "Untitled" }
662−
663−// ── Prompt request body (POST /session/{id}/prompt_async) ──────────────────────
664−@kotlinx.serialization.Serializable
665−private data class PromptBody(val parts: List<TextInput>)
666−
667−@kotlinx.serialization.Serializable
668−private data class TextInput(val type: String = "text", val text: String)
app/src/main/java/soy/iko/opencode/core/Protocol.ktdeleted−148
@@ -1,148 +0,0 @@
1−package soy.iko.opencode.core
2−
3−import kotlinx.serialization.SerialName
4−import kotlinx.serialization.Serializable
5−import kotlinx.serialization.json.Json
6−import kotlinx.serialization.json.JsonElement
7−import kotlinx.serialization.json.JsonObject
8−import kotlinx.serialization.json.JsonPrimitive
9−import kotlinx.serialization.json.booleanOrNull
10−import kotlinx.serialization.json.contentOrNull
11−import kotlinx.serialization.json.decodeFromJsonElement
12−import kotlinx.serialization.json.jsonObject
13−
14−/**
15− * Wire types for the opencode HTTP + SSE API, and lenient parsers for them.
16− *
17− * This is the Kotlin counterpart of the Rust core's `protocol.rs`. It is
18− * deliberately forgiving: unknown JSON fields are ignored and every union has a
19− * catch-all so new server versions never break the stream parser.
20− */
21−object Protocol {
22− val json = Json {
23− ignoreUnknownKeys = true
24− isLenient = true
25− coerceInputValues = true
26− }
27−
28− // ─── Sessions ────────────────────────────────────────────────────────────
29−
30− @Serializable
31− data class WireSession(val id: String, val title: String = "")
32−
33− fun parseSessions(body: String): List<WireSession> =
34− runCatching { json.decodeFromString<List<WireSession>>(body) }.getOrDefault(emptyList())
35−
36− fun parseSession(body: String): WireSession? =
37− runCatching { json.decodeFromString<WireSession>(body) }.getOrNull()
38−
39− // ─── Messages ────────────────────────────────────────────────────────────
40−
41− @Serializable
42− data class WireTime(val created: Long = 0, val completed: Long? = null)
43−
44− @Serializable
45− data class WireError(val name: String = "", val data: JsonElement? = null) {
46− fun message(): String {
47− val msg = ((data as? JsonObject)?.get("message") as? JsonPrimitive)?.contentOrNull
48− return when {
49− !msg.isNullOrEmpty() -> msg
50− name.isNotEmpty() -> name
51− else -> "Unknown error"
52− }
53− }
54− }
55−
56− @Serializable
57− data class WireMessageInfo(
58− val id: String,
59− val role: String = "",
60− @SerialName("sessionID") val sessionId: String = "",
61− val time: WireTime = WireTime(),
62− val error: WireError? = null,
63− )
64−
65− @Serializable
66− data class WireMessageEnvelope(
67− val info: WireMessageInfo,
68− val parts: List<JsonElement> = emptyList(),
69− )
70−
71− fun parseMessages(body: String): List<WireMessageEnvelope> =
72− runCatching { json.decodeFromString<List<WireMessageEnvelope>>(body) }.getOrDefault(emptyList())
73−
74− // ─── Parts (content union, parsed by hand for robustness) ─────────────────
75−
76− sealed interface Part {
77− val id: String
78− val messageId: String
79−
80− data class Text(override val id: String, override val messageId: String, val text: String, val synthetic: Boolean) : Part
81− data class Reasoning(override val id: String, override val messageId: String, val text: String) : Part
82− data class Tool(override val id: String, override val messageId: String, val tool: String, val status: String, val title: String?) : Part
83− }
84−
85− /** Parse a single part JSON object; returns null for kinds we don't render. */
86− fun parsePart(obj: JsonObject): Part? {
87− val id = obj.str("id")
88− val messageId = obj.str("messageID")
89− return when (obj.str("type")) {
90− "text" -> Part.Text(id, messageId, obj.str("text"), obj.bool("synthetic"))
91− "reasoning" -> Part.Reasoning(id, messageId, obj.str("text"))
92− "tool" -> {
93− val state = obj["state"] as? JsonObject
94− val status = state?.str("status")?.ifEmpty { "pending" } ?: "pending"
95− val title = state?.str("title")?.ifEmpty { null } ?: state?.str("error")?.ifEmpty { null }
96− Part.Tool(id, messageId, obj.str("tool"), status, title)
97− }
98− else -> null
99− }
100− }
101−
102− // ─── SSE `/event` stream ──────────────────────────────────────────────────
103−
104− sealed interface Event {
105− data class MessageUpdated(val info: WireMessageInfo) : Event
106− data class PartUpdated(val part: Part, val delta: String?) : Event
107− data class PartRemoved(val messageId: String, val partId: String) : Event
108− data class MessageRemoved(val messageId: String) : Event
109− data class SessionIdle(val sessionId: String) : Event
110− data class SessionError(val sessionId: String?, val error: WireError?) : Event
111− data class SessionUpserted(val info: WireSession) : Event
112− data class SessionDeleted(val sessionId: String?) : Event
113− data object Other : Event
114− }
115−
116− /** Parse one raw SSE `data:` payload into an [Event]; null if malformed. */
117− fun parseEvent(raw: String): Event? {
118− val root = runCatching { json.parseToJsonElement(raw).jsonObject }.getOrNull() ?: return null
119− val props = root["properties"] as? JsonObject ?: JsonObject(emptyMap())
120− return when (root.str("type")) {
121− "message.updated" -> decode<WireMessageInfo>(props["info"])?.let { Event.MessageUpdated(it) }
122− "message.part.updated" -> {
123− val partObj = props["part"] as? JsonObject ?: return Event.Other
124− parsePart(partObj)?.let { Event.PartUpdated(it, props.str("delta").ifEmpty { null }) } ?: Event.Other
125− }
126− "message.part.removed" -> Event.PartRemoved(props.str("messageID"), props.str("partID"))
127− "message.removed" -> Event.MessageRemoved(props.str("messageID"))
128− "session.idle" -> Event.SessionIdle(props.str("sessionID"))
129− "session.error" -> Event.SessionError(props.str("sessionID").ifEmpty { null }, decode<WireError>(props["error"]))
130− "session.created", "session.updated" -> decode<WireSession>(props["info"])?.let { Event.SessionUpserted(it) }
131− "session.deleted" -> Event.SessionDeleted(
132− props.str("sessionID").ifEmpty { null } ?: (props["info"] as? JsonObject)?.str("id")?.ifEmpty { null },
133− )
134− else -> Event.Other
135− }
136− }
137−
138− private inline fun <reified T> decode(el: JsonElement?): T? =
139− el?.let { runCatching { json.decodeFromJsonElement<T>(it) }.getOrNull() }
140−
141− // ─── Small JSON helpers ───────────────────────────────────────────────────
142−
143− private fun JsonObject.str(key: String): String =
144− (this[key] as? JsonPrimitive)?.contentOrNull ?: ""
145−
146− private fun JsonObject.bool(key: String): Boolean =
147− (this[key] as? JsonPrimitive)?.let { it.booleanOrNull ?: it.contentOrNull?.toBoolean() } ?: false
148−}
app/src/main/java/soy/iko/opencode/core/SseClient.ktdeleted−63
@@ -1,63 +0,0 @@
1−package soy.iko.opencode.core
2−
3−import okhttp3.OkHttpClient
4−import okhttp3.Request
5−import okhttp3.Response
6−import okhttp3.sse.EventSource
7−import okhttp3.sse.EventSourceListener
8−import okhttp3.sse.EventSources
9−import java.util.concurrent.TimeUnit
10−
11−/**
12− * Long-lived subscription to the opencode `/event` SSE stream.
13− *
14− * Crux only models request/response HTTP, so the streaming connection lives here
15− * in the shell: each decoded event's `data` payload (a JSON object whose own
16− * `type` field is the discriminant) is handed to [onEvent], which the core then
17− * parses via [Protocol.parseEvent].
18− */
19−class SseClient(
20− private val url: String,
21− private val auth: String?,
22− private val onEvent: (String) -> Unit,
23− private val onStateChange: (Boolean) -> Unit,
24−) {
25− // A dedicated client with no read timeout — the stream is meant to stay open.
26− private val client = OkHttpClient.Builder()
27− .connectTimeout(15, TimeUnit.SECONDS)
28− .readTimeout(0, TimeUnit.SECONDS)
29− .retryOnConnectionFailure(true)
30− .build()
31−
32− private var source: EventSource? = null
33−
34− fun connect() {
35− val builder = Request.Builder().url(url).header("Accept", "text/event-stream")
36− builder.header("User-Agent", "opencode-android/1.0")
37− if (auth != null) builder.header("Authorization", auth)
38− source = EventSources.createFactory(client).newEventSource(builder.build(), listener)
39− }
40−
41− fun close() {
42− source?.cancel()
43− source = null
44− }
45−
46− private val listener = object : EventSourceListener() {
47− override fun onOpen(eventSource: EventSource, response: Response) {
48− onStateChange(true)
49− }
50−
51− override fun onEvent(eventSource: EventSource, id: String?, type: String?, data: String) {
52− if (data.isNotBlank()) onEvent(data)
53− }
54−
55− override fun onClosed(eventSource: EventSource) {
56− onStateChange(false)
57− }
58−
59− override fun onFailure(eventSource: EventSource, t: Throwable?, response: Response?) {
60− onStateChange(false)
61− }
62− }
63−}
app/src/main/java/soy/iko/opencode/core/ViewModel.ktdeleted−77
@@ -1,77 +0,0 @@
1−package soy.iko.opencode.core
2−
3−/**
4− * Immutable snapshot the UI renders. Mirrors the Rust core's `ViewModel`, so
5− * the two implementations stay behaviourally identical.
6− */
7−
8−enum class Screen { Connect, Sessions, Chat }
9−
10−enum class MessageStatus { Sent, Pending, Failed }
11−
12−data class SessionView(
13− val id: String,
14− val title: String,
15−)
16−
17−data class ToolView(
18− val name: String,
19− val status: String,
20− val title: String?,
21−)
22−
23−data class MessageView(
24− val id: String,
25− val role: String,
26− val text: String,
27− val reasoning: String?,
28− val tools: List<ToolView>,
29− val time: Long,
30− val status: MessageStatus,
31− val streaming: Boolean,
32−) {
33− val isUser: Boolean get() = role == "user"
34−}
35−
36−data class UiState(
37− val screen: Screen = Screen.Connect,
38− val serverUrl: String = "",
39− val username: String = "",
40− val password: String = "",
41− val authRequired: Boolean = false,
42− val connected: Boolean = false,
43− val loading: Boolean = false,
44− val error: String? = null,
45− val sessions: List<SessionView> = emptyList(),
46− val currentSessionId: String? = null,
47− val currentSessionTitle: String = "",
48− val messages: List<MessageView> = emptyList(),
49− val draft: String = "",
50− val generating: Boolean = false,
51− val sseConnected: Boolean = true,
52−)
53−
54−/**
55− * Everything the shell can ask the core to do. Internal HTTP results are not
56− * events here (unlike the Rust core, which routes them through `then_send`);
57− * the Kotlin driver awaits them inline in coroutines instead.
58− */
59−sealed interface Event {
60− data class ServerUrlChanged(val url: String) : Event
61− data class UsernameChanged(val value: String) : Event
62− data class PasswordChanged(val value: String) : Event
63− data object Connect : Event
64−
65− data object LoadSessions : Event
66− data class SelectSession(val id: String) : Event
67− data object CreateSession : Event
68− data class DeleteSession(val id: String) : Event
69−
70− data class DraftChanged(val text: String) : Event
71− data class SendMessage(val text: String) : Event
72− data object CancelGeneration : Event
73−
74− data object NavigateToSessions : Event
75− data object NavigateToConnect : Event
76− data object DismissError : Event
77−}
app/src/main/java/soy/iko/opencode/data/model/Agent.ktnew file+37
@@ -0,0 +1,37 @@
1+package soy.iko.opencode.data.model
2+
3+import androidx.compose.runtime.Immutable
4+import kotlinx.serialization.Serializable
5+
6+/** Entry from `GET /agent`. */
7+@Immutable
8+@Serializable
9+data class Agent(
10+ // Defaulted so one name-less element in GET /agent doesn't fail the whole list decode
11+ // (emptying the agent picker) — matches the resilient-decoding philosophy. A blank name is
12+ // degraded but recoverable; a thrown decode is not.
13+ val name: String = "",
14+ val description: String? = null,
15+ val mode: String? = null,
16+ val builtIn: Boolean = false,
17+ val prompt: String? = null,
18+) {
19+ val isPrimary: Boolean get() = mode == "primary"
20+ val displayDescription: String get() = description?.takeIf { it.isNotBlank() } ?: "Custom agent"
21+}
22+
23+/** Entry from `GET /command`. */
24+@Immutable
25+@Serializable
26+data class Command(
27+ // Defaulted so one name-less element in GET /command doesn't fail the whole list decode
28+ // (emptying the command picker) — matches the resilient-decoding philosophy.
29+ val name: String = "",
30+ val description: String? = null,
31+ val agent: String? = null,
32+ val model: String? = null,
33+ val template: String = "",
34+ val subtask: Boolean = false,
35+) {
36+ val displayDescription: String get() = description?.takeIf { it.isNotBlank() } ?: template.take(80)
37+}
app/src/main/java/soy/iko/opencode/data/model/BusEvent.ktnew file+109
@@ -0,0 +1,109 @@
1+package soy.iko.opencode.data.model
2+
3+import kotlinx.serialization.SerialName
4+import kotlinx.serialization.Serializable
5+
6+/**
7+ * The SSE envelope from `GET /event`: `{ "type": "<event>", "properties": { ... } }`.
8+ * Discriminated on `type` (the global discriminator). Unknown events decode to
9+ * [UnknownEvent] so the stream never dies on a new event type.
10+ *
11+ * Property shapes are kept lenient (nullable fields + ignoreUnknownKeys) because they
12+ * drift across opencode versions; the reducer only reads the fields it needs.
13+ */
14+@Serializable
15+sealed interface BusEvent
16+
17+@Serializable
18+@SerialName("server.connected")
19+data object ServerConnected : BusEvent
20+
21+@Serializable
22+@SerialName("message.updated")
23+data class MessageUpdated(val properties: Props) : BusEvent {
24+ @Serializable
25+ data class Props(val info: MessageInfo? = null)
26+}
27+
28+@Serializable
29+@SerialName("message.part.updated")
30+data class MessagePartUpdated(val properties: Props) : BusEvent {
31+ @Serializable
32+ data class Props(
33+ val part: Part,
34+ val sessionID: String? = null,
35+ val messageID: String? = null,
36+ )
37+}
38+
39+@Serializable
40+@SerialName("message.part.removed")
41+data class MessagePartRemoved(val properties: Props) : BusEvent {
42+ @Serializable
43+ data class Props(
44+ val sessionID: String? = null,
45+ val messageID: String? = null,
46+ val partID: String? = null,
47+ )
48+}
49+
50+@Serializable
51+@SerialName("message.removed")
52+data class MessageRemoved(val properties: Props) : BusEvent {
53+ @Serializable
54+ data class Props(
55+ val sessionID: String? = null,
56+ val messageID: String? = null,
57+ )
58+}
59+
60+@Serializable
61+@SerialName("session.idle")
62+data class SessionIdle(val properties: Props) : BusEvent {
63+ @Serializable
64+ data class Props(val sessionID: String? = null)
65+}
66+
67+@Serializable
68+@SerialName("session.error")
69+data class SessionError(val properties: Props) : BusEvent {
70+ @Serializable
71+ data class Props(
72+ val sessionID: String? = null,
73+ val error: kotlinx.serialization.json.JsonElement? = null,
74+ )
75+}
76+
77+@Serializable
78+@SerialName("session.updated")
79+data class SessionUpdated(val properties: Props) : BusEvent {
80+ @Serializable
81+ data class Props(val info: Session? = null, val sessionID: String? = null)
82+}
83+
84+@Serializable
85+@SerialName("session.deleted")
86+data class SessionDeleted(val properties: Props) : BusEvent {
87+ @Serializable
88+ data class Props(val info: Session? = null, val sessionID: String? = null)
89+}
90+
91+@Serializable
92+@SerialName("permission.updated")
93+data class PermissionUpdated(val properties: Permission) : BusEvent
94+
95+@Serializable
96+@SerialName("permission.replied")
97+data class PermissionReplied(val properties: Props) : BusEvent {
98+ @Serializable
99+ data class Props(
100+ val sessionID: String? = null,
101+ val permissionID: String? = null,
102+ val response: String? = null,
103+ )
104+}
105+
106+/** Fallback for any event type we don't model. */
107+@Serializable
108+@SerialName("__unknown")
109+data object UnknownEvent : BusEvent
app/src/main/java/soy/iko/opencode/data/model/Common.ktnew file+41
@@ -0,0 +1,41 @@
1+package soy.iko.opencode.data.model
2+
3+import androidx.compose.runtime.Immutable
4+import kotlinx.serialization.Serializable
5+
6+@Immutable
7+@Serializable
8+data class TimeInfo(
9+ val created: Long? = null,
10+ val updated: Long? = null,
11+ val completed: Long? = null,
12+)
13+
14+/** Timing carried by message parts and tool states. The opencode server sends `time: { start,
15+ * end?, compacted? }` for these (unlike Session/MessageInfo/Permission, which use the
16+ * created/updated/completed shape of [TimeInfo]). With `ignoreUnknownKeys`, modelling these
17+ * fields with [TimeInfo] would silently discard start/end/compacted — every part/tool time
18+ * would decode to all-nulls — so this separate shape exists for them. */
19+@Immutable
20+@Serializable
21+data class PartTimeInfo(
22+ val start: Long? = null,
23+ val end: Long? = null,
24+ val compacted: Long? = null,
25+)
26+
27+@Immutable
28+@Serializable
29+data class Tokens(
30+ val input: Long = 0,
31+ val output: Long = 0,
32+ val reasoning: Long = 0,
33+ val cache: Cache = Cache(),
34+) {
35+ @Serializable
36+ @Immutable
37+ data class Cache(
38+ val read: Long = 0,
39+ val write: Long = 0,
40+ )
41+}
app/src/main/java/soy/iko/opencode/data/model/Files.ktnew file+44
@@ -0,0 +1,44 @@
1+package soy.iko.opencode.data.model
2+
3+import androidx.compose.runtime.Immutable
4+import kotlinx.serialization.Serializable
5+
6+/** Entry from `GET /file?path=` (directory listing). */
7+@Immutable
8+@Serializable
9+data class FileNode(
10+ val name: String = "",
11+ val path: String = "",
12+ val type: String? = null,
13+ // Size in bytes and last-modified epoch-ms. The server doesn't currently emit these, so
14+ // they default to null and the browser renders nothing extra; optional + defaulted keeps
15+ // the client forward-compatible (resilient decoding) so the moment the server adds them
16+ // the listing shows size/date with no client change.
17+ val size: Long? = null,
18+ val mtime: Long? = null,
19+) {
20+ val isDirectory: Boolean get() = type == "directory"
21+}
22+
23+/** Response of `GET /file/content?path=`. */
24+@Immutable
25+@Serializable
26+data class FileContent(
27+ val type: String? = null, // "text" | "binary"
28+ val content: String = "",
29+ val diff: String? = null,
30+ val mimeType: String? = null,
31+ val encoding: String? = null,
32+) {
33+ val isBinary: Boolean get() = type == "binary" || encoding == "base64"
34+}
35+
36+/** Entry from `GET /file/status` (VCS status). */
37+@Immutable
38+@Serializable
39+data class FileStatusEntry(
40+ val path: String = "",
41+ val added: Int = 0,
42+ val removed: Int = 0,
43+ val status: String? = null, // "added" | "deleted" | "modified"
44+)
app/src/main/java/soy/iko/opencode/data/model/Message.ktnew file+69
@@ -0,0 +1,69 @@
1+package soy.iko.opencode.data.model
2+
3+import androidx.compose.runtime.Immutable
4+import kotlinx.serialization.SerialName
5+import kotlinx.serialization.Serializable
6+import kotlinx.serialization.json.JsonElement
7+import kotlinx.serialization.json.JsonClassDiscriminator
8+
9+/**
10+ * The `info` object of a message. Discriminated on `role`, which differs from the
11+ * global `type` discriminator used by [Part], so this hierarchy overrides it.
12+ */
13+@OptIn(kotlinx.serialization.ExperimentalSerializationApi::class)
14+@Serializable
15+@JsonClassDiscriminator("role")
16+sealed interface MessageInfo {
17+ val id: String
18+ val sessionID: String
19+ val time: TimeInfo?
20+}
21+
22+@Immutable
23+@Serializable
24+@SerialName("user")
25+data class UserMessage(
26+ override val id: String = "",
27+ override val sessionID: String = "",
28+ override val time: TimeInfo? = null,
29+) : MessageInfo
30+
31+@Immutable
32+@Serializable
33+@SerialName("assistant")
34+data class AssistantMessage(
35+ override val id: String = "",
36+ override val sessionID: String = "",
37+ val parentID: String? = null,
38+ val providerID: String? = null,
39+ val modelID: String? = null,
40+ val cost: Double? = null,
41+ val tokens: Tokens? = null,
42+ // JsonElement (not JsonObject) so a server that sends `error` as a bare string/array — or any
43+ // non-object shape — decodes instead of throwing mid-AssistantMessage (the MessageInfo
44+ // polymorphic fallback can't catch that, since `role` already matched). Mirrors
45+ // SessionError.error. Not currently read by the UI, but kept tolerant for forward-compat.
46+ val error: JsonElement? = null,
47+ override val time: TimeInfo? = null,
48+) : MessageInfo {
49+ /** A run is complete once the server stamps a completion time. */
50+ val isComplete: Boolean get() = time?.completed != null
51+}
52+
53+/** Fallback for an unrecognized `role`, so new server roles never crash decoding. */
54+@Immutable
55+@Serializable
56+@SerialName("__unknown")
57+data class UnknownMessage(
58+ override val id: String = "",
59+ override val sessionID: String = "",
60+ override val time: TimeInfo? = null,
61+) : MessageInfo
62+
63+/** A message together with its parts — the shape returned by message endpoints and the prompt response. */
64+@Immutable
65+@Serializable
66+data class MessageWithParts(
67+ val info: MessageInfo,
68+ val parts: List<Part> = emptyList(),
69+)
app/src/main/java/soy/iko/opencode/data/model/Part.ktnew file+127
@@ -0,0 +1,127 @@
1+package soy.iko.opencode.data.model
2+
3+import androidx.compose.runtime.Immutable
4+import kotlinx.serialization.SerialName
5+import kotlinx.serialization.Serializable
6+import kotlinx.serialization.json.JsonElement
7+import kotlinx.serialization.json.JsonObject
8+import kotlinx.serialization.json.JsonPrimitive
9+
10+/**
11+ * A message part — a discriminated union on `type` (the global class discriminator).
12+ * Unknown types decode to [UnknownPart] via the registered polymorphic default,
13+ * so the stream survives server additions.
14+ *
15+ * Every part carries [id] plus its parent [messageID]/[sessionID], which the reducer
16+ * uses to attach streamed parts to the right message.
17+ */
18+@Serializable
19+sealed interface Part {
20+ val id: String
21+ val messageID: String?
22+ val sessionID: String?
23+}
24+
25+@Immutable
26+@Serializable
27+@SerialName("text")
28+data class TextPart(
29+ override val id: String = "",
30+ override val messageID: String? = null,
31+ override val sessionID: String? = null,
32+ val text: String = "",
33+ val synthetic: Boolean = false,
34+ val ignored: Boolean = false,
35+ val time: PartTimeInfo? = null,
36+) : Part
37+
38+@Immutable
39+@Serializable
40+@SerialName("reasoning")
41+data class ReasoningPart(
42+ override val id: String = "",
43+ override val messageID: String? = null,
44+ override val sessionID: String? = null,
45+ val text: String = "",
46+ val time: PartTimeInfo? = null,
47+) : Part
48+
49+@Immutable
50+@Serializable
51+@SerialName("tool")
52+data class ToolPart(
53+ override val id: String = "",
54+ override val messageID: String? = null,
55+ override val sessionID: String? = null,
56+ val callID: String = "",
57+ val tool: String = "",
58+ val state: ToolState = ToolPending,
59+) : Part
60+
61+@Immutable
62+@Serializable
63+@SerialName("file")
64+data class FilePart(
65+ override val id: String = "",
66+ override val messageID: String? = null,
67+ override val sessionID: String? = null,
68+ val mime: String? = null,
69+ val filename: String? = null,
70+ val url: String? = null,
71+ // opencode's message-v2 schema sends `source` as a structured FilePartSource OBJECT
72+ // ({ type, path, text, ... }), not a string, for @-file mentions. Typing it as String
73+ // threw a SerializationException on decode — which the SSE path silently swallows, so the
74+ // whole part vanished. Keep it as a tolerant JsonElement (mirroring Permission.pattern) and
75+ // read the path via [sourcePath]; a bare-string source is still tolerated.
76+ val source: JsonElement? = null,
77+) : Part
78+
79+/** The file path referenced by a [FilePart.source]. opencode sends `source` as a FilePartSource
80+ * object whose `path` field holds the file path; older/other shapes may send a bare string.
81+ * Returns null when there's no usable path. */
82+val FilePart.sourcePath: String?
83+ get() = when (val s = source) {
84+ is JsonPrimitive -> if (s.isString) s.content else null
85+ is JsonObject -> (s["path"] as? JsonPrimitive)?.let { if (it.isString) it.content else null }
86+ else -> null
87+ }
88+
89+@Immutable
90+@Serializable
91+@SerialName("step-start")
92+data class StepStartPart(
93+ override val id: String = "",
94+ override val messageID: String? = null,
95+ override val sessionID: String? = null,
96+) : Part
97+
98+@Immutable
99+@Serializable
100+@SerialName("agent")
101+data class AgentPart(
102+ override val id: String = "",
103+ override val messageID: String? = null,
104+ override val sessionID: String? = null,
105+ val name: String = "",
106+) : Part
107+
108+@Immutable
109+@Serializable
110+@SerialName("step-finish")
111+data class StepFinishPart(
112+ override val id: String = "",
113+ override val messageID: String? = null,
114+ override val sessionID: String? = null,
115+ val cost: Double? = null,
116+ val tokens: Tokens? = null,
117+) : Part
118+
119+/** Fallback for an unrecognized `type`. */
120+@Immutable
121+@Serializable
122+@SerialName("__unknown")
123+data class UnknownPart(
124+ override val id: String = "",
125+ override val messageID: String? = null,
126+ override val sessionID: String? = null,
127+) : Part
app/src/main/java/soy/iko/opencode/data/model/Permission.ktnew file+58
@@ -0,0 +1,58 @@
1+package soy.iko.opencode.data.model
2+
3+import androidx.compose.runtime.Immutable
4+import kotlinx.serialization.Serializable
5+import kotlinx.serialization.json.JsonElement
6+import kotlinx.serialization.json.JsonArray
7+import kotlinx.serialization.json.JsonPrimitive
8+
9+/**
10+ * A permission request from `permission.updated`. The tool detail lives in [type] +
11+ * [pattern] + [metadata]; [pattern] is a string or array of strings on the wire, so
12+ * it's kept as a [JsonElement] and flattened for display.
13+ */
14+@Immutable
15+@Serializable
16+data class Permission(
17+ val id: String = "",
18+ val sessionID: String = "",
19+ val type: String? = null,
20+ val pattern: JsonElement? = null,
21+ val messageID: String? = null,
22+ val callID: String? = null,
23+ val title: String? = null,
24+ // JsonElement (not JsonObject) so a non-object `metadata` shape decodes instead of throwing,
25+ // matching [pattern]. Not read by the UI today, but kept tolerant for forward-compat.
26+ val metadata: JsonElement? = null,
27+ val time: TimeInfo? = null,
28+) {
29+ val patternText: String?
30+ get() = when (val p = pattern) {
31+ null -> null
32+ is JsonPrimitive -> p.content
33+ // Only a JSON array is flattened; any other shape (e.g. an object) would make
34+ // the previous unguarded `p.jsonArray` throw during composition, so treat it
35+ // as "no displayable pattern" instead.
36+ is JsonArray -> p.mapNotNull { (it as? JsonPrimitive)?.content }.joinToString(", ").ifEmpty { null }
37+ else -> null
38+ }
39+}
40+
41+/** Allowed values for the permission response body.
42+ *
43+ * [SESSION] is a client-side scope: it auto-responds [ONCE] to subsequent permission requests
44+ * whose (type, pattern) match a remembered set, without showing the dialog. The set lives in
45+ * [soy.iko.opencode.ui.chat.ChatViewModel] and is cleared on connection change. It is NOT sent
46+ * to the server (the server only knows `once`/`always`/`reject`); the VM maps it to [ONCE] on
47+ * the wire and tracks the grant locally. This gives users a middle ground between "allow once"
48+ * (re-prompts on every file read) and "always" (persists in the server config outliving the app). */
49+enum class PermissionResponse(val wire: String) {
50+ ONCE("once"),
51+ ALWAYS("always"),
52+ REJECT("reject"),
53+ SESSION("once"),
54+}
55+
56+/** Body for `POST /session/:id/permissions/:permissionID`. */
57+@Serializable
58+data class PermissionReplyBody(val response: String)
app/src/main/java/soy/iko/opencode/data/model/Project.ktnew file+32
@@ -0,0 +1,32 @@
1+package soy.iko.opencode.data.model
2+
3+import androidx.compose.runtime.Immutable
4+import kotlinx.serialization.Serializable
5+
6+/**
7+ * A project known to the server, from `GET /project`. A project corresponds to a git
8+ * worktree root; [worktree] is the absolute path the agent can run in. Used to populate
9+ * the directory picker when creating a session in a specific directory.
10+ */
11+@Immutable
12+@Serializable
13+data class Project(
14+ val id: String = "",
15+ val worktree: String = "",
16+ val vcs: String? = null,
17+ val time: TimeInfo? = null,
18+)
19+
20+/**
21+ * The server's current path info, from `GET /path`. [directory] is the working directory
22+ * a request with no `directory` override resolves to (the server's launch cwd), shown in
23+ * the picker as the "server default" option.
24+ */
25+@Immutable
26+@Serializable
27+data class PathInfo(
28+ val directory: String? = null,
29+ val worktree: String? = null,
30+ val state: String? = null,
31+ val config: String? = null,
32+)
app/src/main/java/soy/iko/opencode/data/model/Prompt.ktnew file+68
@@ -0,0 +1,68 @@
1+package soy.iko.opencode.data.model
2+
3+import kotlinx.serialization.SerialName
4+import kotlinx.serialization.Serializable
5+
6+/** Body for `POST /session/:id/message`. */
7+@Serializable
8+data class PromptRequest(
9+ val parts: List<PromptPart>,
10+ val model: ModelRef? = null,
11+ val agent: String? = null,
12+)
13+
14+/**
15+ * An input part for a prompt — a discriminated union on `type` (the global class
16+ * discriminator). We compose text and file (image/document attachment) parts; the server
17+ * models more (agent, subtask) that this client doesn't send.
18+ */
19+@Serializable
20+sealed interface PromptPart
21+
22+@Serializable
23+@SerialName("text")
24+data class TextPromptPart(val text: String) : PromptPart
25+
26+/**
27+ * A file/image attachment. [url] must be a data URL carrying the base64 payload —
28+ * `data:<mime>;base64,<bytes>` — which is how the server embeds media for the model to
29+ * see (see opencode's message-v2.ts). [mime] must match the payload; the server forwards
30+ * it as the model's mediaType.
31+ */
32+@Serializable
33+@SerialName("file")
34+data class FilePromptPart(
35+ val mime: String,
36+ val url: String,
37+ val filename: String? = null,
38+) : PromptPart
39+
40+/** Body for `POST /session/:id/command`. */
41+@Serializable
42+data class CommandRequest(
43+ val command: String,
44+ val arguments: String = "",
45+ val agent: String? = null,
46+)
47+
48+/** Body for `POST /session/:id/revert`. */
49+@Serializable
50+data class RevertRequest(
51+ val messageID: String,
52+ val partID: String? = null,
53+)
54+
55+/** Body for `POST /session/:id/summarize`. */
56+@Serializable
57+data class SummarizeRequest(
58+ val providerID: String,
59+ val modelID: String,
60+)
61+
62+/** Body for `POST /session/:id/shell`. */
63+@Serializable
64+data class ShellRequest(
65+ val agent: String,
66+ val command: String,
67+ val model: ModelRef? = null,
68+)
app/src/main/java/soy/iko/opencode/data/model/Provider.ktnew file+84
@@ -0,0 +1,84 @@
1+package soy.iko.opencode.data.model
2+
3+import androidx.compose.runtime.Immutable
4+import kotlinx.serialization.Serializable
5+
6+/** Response of `GET /config/providers`. */
7+@Immutable
8+@Serializable
9+data class ProvidersResponse(
10+ val providers: List<Provider> = emptyList(),
11+ val default: Map<String, String> = emptyMap(),
12+)
13+
14+@Immutable
15+@Serializable
16+data class Provider(
17+ // Defaulted so one id-less element in GET /config/providers doesn't fail the whole list decode
18+ // (emptying the model picker) — matches MessageInfo's defaulted id and the resilient-decoding
19+ // philosophy. A blank id is degraded but recoverable; a thrown decode is not.
20+ val id: String = "",
21+ val name: String? = null,
22+ val models: Map<String, ModelInfo> = emptyMap(),
23+) {
24+ val displayName: String get() = name?.takeIf { it.isNotBlank() } ?: id
25+}
26+
27+@Immutable
28+@Serializable
29+data class ModelInfo(
30+ val id: String? = null,
31+ val name: String? = null,
32+) {
33+ fun displayName(modelKey: String): String =
34+ name?.takeIf { it.isNotBlank() } ?: id ?: modelKey
35+}
36+
37+/** A flattened provider/model selection used by the prompt body and the model picker. */
38+@Immutable
39+@Serializable
40+data class ModelRef(
41+ val providerID: String,
42+ val modelID: String,
43+)
44+
45+/** A single selectable entry in the model picker, flattened from [ProvidersResponse]. */
46+@Immutable
47+data class ModelOption(
48+ val providerID: String,
49+ val modelID: String,
50+ val providerLabel: String,
51+ val modelLabel: String,
52+) {
53+ val ref: ModelRef get() = ModelRef(providerID, modelID)
54+}
55+
56+/** Flatten providers into a sorted list of pickable options. */
57+fun ProvidersResponse.toOptions(): List<ModelOption> =
58+ providers.flatMap { provider ->
59+ provider.models.map { (modelKey, info) ->
60+ ModelOption(
61+ providerID = provider.id,
62+ modelID = info.id ?: modelKey,
63+ providerLabel = provider.displayName,
64+ modelLabel = info.displayName(modelKey),
65+ )
66+ }
67+ }.sortedWith(
68+ // CASE_INSENSITIVE_ORDER compares in place; compareBy { it.x.lowercase() } would
69+ // allocate a fresh lowercased string on every comparison (O(N log N) allocations).
70+ compareBy<ModelOption, String>(String.CASE_INSENSITIVE_ORDER) { it.providerLabel }
71+ .thenBy(String.CASE_INSENSITIVE_ORDER) { it.modelLabel },
72+ )
73+
74+/** The default option to preselect: the server's default for some provider, else the first model. */
75+fun ProvidersResponse.defaultOption(options: List<ModelOption> = toOptions()): ModelOption? {
76+ // Try each configured provider default in turn and take the first whose model actually
77+ // survives in `options` (the list may be filtered). Checking only the first `default` entry
78+ // would mispreselect an arbitrary first option whenever that one provider's default model
79+ // isn't present, ignoring the other configured defaults.
80+ for ((providerID, modelID) in default) {
81+ options.firstOrNull { it.providerID == providerID && it.modelID == modelID }?.let { return it }
82+ }
83+ return options.firstOrNull()
84+}
app/src/main/java/soy/iko/opencode/data/model/Search.ktnew file+101
@@ -0,0 +1,101 @@
1+package soy.iko.opencode.data.model
2+
3+import androidx.compose.runtime.Immutable
4+import kotlinx.serialization.SerialName
5+import kotlinx.serialization.Serializable
6+
7+/**
8+ * One ripgrep match from `GET /find?pattern=` (content search). [path] and [lines] are
9+ * wrapped `{ text }` objects on the wire; [submatches] carry the column offsets of each
10+ * hit within the line so the UI can highlight them.
11+ */
12+@Immutable
13+@Serializable
14+data class FindMatch(
15+ val path: TextField = TextField(),
16+ val lines: TextField = TextField(),
17+ @SerialName("line_number") val lineNumber: Int = 0,
18+ @SerialName("absolute_offset") val absoluteOffset: Long = 0,
19+ val submatches: List<SubMatch> = emptyList(),
20+) {
21+ @Immutable
22+ @Serializable
23+ data class TextField(val text: String = "")
24+
25+ @Immutable
26+ @Serializable
27+ data class SubMatch(
28+ val start: Int = 0,
29+ val end: Int = 0,
30+ )
31+
32+ /** The matched file's path (relative to the search root). */
33+ val filePath: String get() = path.text
34+
35+ /** The matching line's text, trimmed of a trailing newline for display. */
36+ val lineText: String get() = lines.text.trimEnd('\n', '\r')
37+}
38+
39+/**
40+ * One workspace symbol from `GET /find/symbol?query=`. [kind] is the LSP `SymbolKind`
41+ * integer; [location.uri] is a file URI and [location.range] the symbol's span.
42+ */
43+@Immutable
44+@Serializable
45+data class SymbolResult(
46+ val name: String = "",
47+ val kind: Int = 0,
48+ val location: Location = Location(),
49+) {
50+ @Immutable
51+ @Serializable
52+ data class Location(
53+ val uri: String = "",
54+ val range: Range = Range(),
55+ )
56+
57+ @Immutable
58+ @Serializable
59+ data class Range(
60+ val start: Pos = Pos(),
61+ val end: Pos = Pos(),
62+ )
63+
64+ @Immutable
65+ @Serializable
66+ data class Pos(
67+ val line: Int = 0,
68+ val character: Int = 0,
69+ )
70+
71+ /** The file path portion of [Location.uri], stripping a `file://` scheme when present.
72+ * Handles both empty-authority (`file:///abs/path`) and non-empty-authority
73+ * (`file://host/abs/path`) URIs by parsing the URI and taking its path component,
74+ * rather than a prefix strip that would leave the authority glued to the path. */
75+ val filePath: String
76+ get() {
77+ val uri = location.uri
78+ // Only parse file: URIs; for anything else, return as-is.
79+ if (!uri.startsWith("file:")) return uri
80+ return runCatching { java.net.URI(uri).path }
81+ .getOrNull()
82+ ?.takeIf { it.isNotBlank() }
83+ ?: uri.removePrefix("file://").ifBlank { uri }
84+ }
85+
86+ /** 1-based line for display; the wire range is 0-based. */
87+ val displayLine: Int get() = location.range.start.line + 1
88+}
89+
90+// LSP SymbolKind → label. A map lookup (rather than a large `when`) keeps the accessor
91+// trivial. Indices follow the LSP SymbolKind enum.
92+private val symbolKindLabels = mapOf(
93+ 1 to "file", 2 to "module", 3 to "namespace", 4 to "package", 5 to "class",
94+ 6 to "method", 7 to "property", 8 to "field", 9 to "constructor", 10 to "enum",
95+ 11 to "interface", 12 to "function", 13 to "variable", 14 to "constant", 15 to "string",
96+ 16 to "number", 17 to "boolean", 18 to "array", 19 to "object", 20 to "key",
97+ 22 to "enum member", 23 to "struct", 26 to "type",
98+)
99+
100+/** Human-readable label for an LSP [SymbolResult.kind]. */
101+fun symbolKindLabel(kind: Int): String = symbolKindLabels[kind] ?: "symbol"
app/src/main/java/soy/iko/opencode/data/model/ServerProfile.ktnew file+27
@@ -0,0 +1,27 @@
1+package soy.iko.opencode.data.model
2+
3+import androidx.compose.runtime.Immutable
4+
5+/**
6+ * A saved opencode server connection. The [password] for HTTP Basic auth is resolved
7+ * separately from EncryptedSharedPreferences and is null when not loaded/needed.
8+ */
9+@Immutable
10+data class ServerProfile(
11+ val id: String,
12+ val label: String,
13+ val baseUrl: String,
14+ val username: String? = null,
15+ val password: String? = null,
16+ val lastUsed: Long = 0,
17+ // When true, the client upgrades a cleartext http:// URL to https:// so credentials and
18+ // traffic are never sent over the wire in the clear for this server.
19+ val requireHttps: Boolean = false,
20+ // Optional OkHttp certificate pin(s) ("sha256/<base64>", whitespace/comma-separated for
21+ // multiple) applied to this server's TLS connection for defense against a rogue CA.
22+ val certPin: String? = null,
23+) {
24+ val hasAuth: Boolean get() = !username.isNullOrBlank()
25+
26+ val displayLabel: String get() = label.takeIf { it.isNotBlank() } ?: baseUrl
27+}
app/src/main/java/soy/iko/opencode/data/model/Session.ktnew file+68
@@ -0,0 +1,68 @@
1+package soy.iko.opencode.data.model
2+
3+import androidx.compose.runtime.Immutable
4+import kotlinx.serialization.Serializable
5+
6+/** A session as returned by `GET /session` and `POST /session`. */
7+@Immutable
8+@Serializable
9+data class Session(
10+ // Defaulted so one id-less element in GET /session doesn't fail the whole list decode
11+ // (emptying the session list) — matches MessageInfo's defaulted id and the resilient-decoding
12+ // philosophy. A blank id is degraded but recoverable; a thrown decode is not.
13+ val id: String = "",
14+ val parentID: String? = null,
15+ val title: String? = null,
16+ val version: String? = null,
17+ val time: TimeInfo? = null,
18+ // The absolute worktree/directory the agent runs in for this session. Set server-side
19+ // from the `directory` query param at creation; persisted per session so the client
20+ // needn't re-send it on follow-up calls (see OpencodeApiClient.createSession).
21+ val directory: String? = null,
22+ val projectID: String? = null,
23+ // Present only while the session is shared (POST /session/:id/share); cleared on unshare.
24+ val share: Share? = null,
25+ // Present only while a revert checkpoint is active (POST /session/:id/revert); cleared on unrevert.
26+ val revert: Revert? = null,
27+) {
28+ val displayTitle: String get() = title?.takeIf { it.isNotBlank() } ?: "Untitled session"
29+
30+ /** A compact form of [directory] for list/subtitle display: the last path segment,
31+ * or the full path when it has no separators. Null/blank when no directory is set. */
32+ val displayDirectory: String?
33+ get() = directory?.takeIf { it.isNotBlank() }?.trimEnd('/')?.substringAfterLast('/')?.takeIf { it.isNotBlank() }
34+
35+ /** True while the session has an active public share link. */
36+ val isShared: Boolean get() = !share?.url.isNullOrBlank()
37+
38+ /** True while a revert checkpoint is active (messages after it are hidden server-side). */
39+ val isReverted: Boolean get() = revert != null
40+
41+ /** Public share link for the session, from `POST /session/:id/share`. */
42+ @Immutable
43+ @Serializable
44+ data class Share(val url: String = "")
45+
46+ /** The active revert checkpoint, from `POST /session/:id/revert`. */
47+ @Immutable
48+ @Serializable
49+ data class Revert(
50+ val messageID: String = "",
51+ val partID: String? = null,
52+ val snapshot: String? = null,
53+ val diff: String? = null,
54+ )
55+}
56+
57+/** Body for `POST /session`. */
58+@Serializable
59+data class CreateSessionRequest(
60+ val parentID: String? = null,
61+ val title: String? = null,
62+)
63+
64+/** Body for `PATCH /session/:id`. */
65+@Serializable
66+data class UpdateSessionRequest(
67+ val title: String? = null,
68+)
app/src/main/java/soy/iko/opencode/data/model/Todo.ktnew file+125
@@ -0,0 +1,125 @@
1+package soy.iko.opencode.data.model
2+
3+import androidx.compose.runtime.Immutable
4+import kotlinx.serialization.Serializable
5+import kotlinx.serialization.builtins.ListSerializer
6+import kotlinx.serialization.json.Json
7+import kotlinx.serialization.json.JsonElement
8+
9+/** The tool name opencode uses to write the agent's task plan. */
10+const val TODO_WRITE_TOOL = "todowrite"
11+
12+/**
13+ * A single entry in an agent's task plan, as written by opencode's `todowrite` tool.
14+ * Every field is defaulted and decoding ignores unknown keys (see [parseTodos]) so a
15+ * change to the tool's payload shape degrades to a best-effort render instead of
16+ * dropping the whole plan.
17+ */
18+@Immutable
19+@Serializable
20+data class TodoItem(
21+ val id: String = "",
22+ val content: String = "",
23+ val status: String = "pending",
24+ val priority: String? = null,
25+)
26+
27+/** Normalized lifecycle of a [TodoItem]; [UNKNOWN] covers any status the client doesn't model. */
28+enum class TodoStatus { PENDING, IN_PROGRESS, COMPLETED, CANCELLED, UNKNOWN }
29+
30+/** Normalized priority of a [TodoItem]; [NONE] covers null/unmodelled priorities. The order
31+ * matters: HIGH > MEDIUM > LOW, used by [TodoPlan] tinting and a11y labels. */
32+enum class TodoPriority { HIGH, MEDIUM, LOW, NONE }
33+
34+fun TodoItem.priorityEnum(): TodoPriority = when ((priority ?: "").trim().lowercase()) {
35+ "high", "h", "1", "urgent", "critical" -> TodoPriority.HIGH
36+ "medium", "med", "m", "normal", "2" -> TodoPriority.MEDIUM
37+ "low", "l", "3", "minor", "backlog" -> TodoPriority.LOW
38+ else -> TodoPriority.NONE
39+}
40+
41+fun TodoItem.statusEnum(): TodoStatus = when (status.trim().lowercase()) {
42+ "pending", "todo", "" -> TodoStatus.PENDING
43+ "in_progress", "in-progress", "inprogress", "running", "active" -> TodoStatus.IN_PROGRESS
44+ "completed", "complete", "done" -> TodoStatus.COMPLETED
45+ "cancelled", "canceled", "skipped" -> TodoStatus.CANCELLED
46+ else -> TodoStatus.UNKNOWN
47+}
48+
49+@Serializable
50+private data class TodoWriteInput(val todos: List<TodoItem> = emptyList())
51+
52+// A lenient decoder mirroring OpencodeJson's resilience (unknown keys ignored, nulls
53+// coerced to defaults) without pulling the network layer into data/model.
54+private val todoJson = Json {
55+ ignoreUnknownKeys = true
56+ isLenient = true
57+ coerceInputValues = true
58+ explicitNulls = false
59+}
60+
61+/**
62+ * Extract the todo list from a `todowrite` tool call's input JSON. The tool sends the
63+ * full list on every write, so a single call's input is a complete plan snapshot.
64+ * Accepts either the documented `{ "todos": [...] }` object or a bare array, and returns
65+ * an empty list on anything unparseable.
66+ */
67+fun parseTodos(input: JsonElement?): List<TodoItem> {
68+ if (input == null) return emptyList()
69+ return runCatching {
70+ todoJson.decodeFromJsonElement(TodoWriteInput.serializer(), input).todos
71+ }.recoverCatching {
72+ todoJson.decodeFromJsonElement(ListSerializer(TodoItem.serializer()), input)
73+ }.getOrDefault(emptyList())
74+}
75+
76+/**
77+ * The current task plan for a conversation: the todos of the most recent `todowrite`
78+ * tool call that carries a non-empty, parseable list. opencode rewrites the whole list
79+ * on every write, so a single call is a complete snapshot; scanning newest-first and
80+ * skipping empties avoids a mid-stream write (input not yet arrived) briefly blanking a
81+ * plan that's still valid.
82+ *
83+ * A caller-side cache ([TodoPlanCache]) memoizes the result against the input list's
84+ * identity so a back-to-back emission of the same list instance (e.g. a `combine` that
85+ * re-evaluates without a real upstream change) skips the O(messages×parts) scan. The
86+ * scan still runs whenever the list instance changes (every streamed snapshot), but
87+ * `distinctUntilChanged` downstream dedupes the *result* so the UI doesn't recompose.
88+ */
89+fun currentTodoPlan(messages: List<MessageWithParts>): List<TodoItem> {
90+ for (mi in messages.indices.reversed()) {
91+ val parts = messages[mi].parts
92+ for (pi in parts.indices.reversed()) {
93+ val part = parts[pi]
94+ if (part is ToolPart && part.tool.equals(TODO_WRITE_TOOL, ignoreCase = true)) {
95+ val todos = parseTodos(part.state.inputElement())
96+ if (todos.isNotEmpty()) return todos
97+ }
98+ }
99+ }
100+ return emptyList()
101+}
102+
103+/**
104+ * A tiny memoization layer for [currentTodoPlan]: if the same list instance is passed
105+ * twice in a row, the cached result is returned without re-scanning. The [ChatViewModel]
106+ * `todoPlan` flow's `combine` can re-emit the same list instance when an unrelated input
107+ * (e.g. the optimistic-messages StateFlow) updates without the SSE stream having changed;
108+ * without this cache, every such re-emit re-scans every part of every message off-main.
109+ *
110+ * Not thread-safe by design — it's only read from the `flowOn(Dispatchers.Default)`
111+ * operator's coroutine, which is single-threaded per emission. The cache holds a single
112+ * `(ref, result)` pair; a different list instance replaces it.
113+ */
114+class TodoPlanCache {
115+ private var lastRef: List<MessageWithParts>? = null
116+ private var lastResult: List<TodoItem> = emptyList()
117+
118+ fun plan(messages: List<MessageWithParts>): List<TodoItem> {
119+ if (messages === lastRef) return lastResult
120+ val result = currentTodoPlan(messages)
121+ lastRef = messages
122+ lastResult = result
123+ return result
124+ }
125+}
app/src/main/java/soy/iko/opencode/data/model/ToolState.ktnew file+62
@@ -0,0 +1,62 @@
1+package soy.iko.opencode.data.model
2+
3+import androidx.compose.runtime.Immutable
4+import kotlinx.serialization.SerialName
5+import kotlinx.serialization.Serializable
6+import kotlinx.serialization.json.JsonElement
7+import kotlinx.serialization.json.JsonClassDiscriminator
8+
9+/**
10+ * The state machine of a tool call, discriminated on `status`.
11+ * pending -> running -> completed | error
12+ */
13+@OptIn(kotlinx.serialization.ExperimentalSerializationApi::class)
14+@Serializable
15+@JsonClassDiscriminator("status")
16+sealed interface ToolState
17+
18+@Serializable
19+@SerialName("pending")
20+data object ToolPending : ToolState
21+
22+@Immutable
23+@Serializable
24+@SerialName("running")
25+data class ToolRunning(
26+ val input: JsonElement? = null,
27+ val title: String? = null,
28+ val time: PartTimeInfo? = null,
29+) : ToolState
30+
31+@Immutable
32+@Serializable
33+@SerialName("completed")
34+data class ToolCompleted(
35+ val input: JsonElement? = null,
36+ val output: String? = null,
37+ val title: String? = null,
38+ val time: PartTimeInfo? = null,
39+) : ToolState
40+
41+@Immutable
42+@Serializable
43+@SerialName("error")
44+data class ToolError(
45+ val input: JsonElement? = null,
46+ val error: String? = null,
47+ val time: PartTimeInfo? = null,
48+) : ToolState
49+
50+/** Fallback for an unrecognized `status`. */
51+@Serializable
52+@SerialName("__unknown")
53+data object ToolUnknown : ToolState
54+
55+/** The raw input arguments carried by a tool call, present on the running/completed/error
56+ * states (pending/unknown carry none). */
57+fun ToolState.inputElement(): JsonElement? = when (this) {
58+ is ToolRunning -> input
59+ is ToolCompleted -> input
60+ is ToolError -> input
61+ else -> null
62+}
app/src/main/java/soy/iko/opencode/data/network/CertPinUtil.ktnew file+35
@@ -0,0 +1,35 @@
1+package soy.iko.opencode.data.network
2+
3+import soy.iko.opencode.data.model.ServerProfile
4+
5+/**
6+ * Shared helpers for resolving a [ServerProfile]'s certificate-pin field and the
7+ * http→https upgrade it implies. The logic is used in three places that must agree:
8+ * [HttpClientFactory] (the REST/SSE channel), [soy.iko.opencode.OpencodeApp]'s
9+ * PinnedImageCallFactory (the image-loading channel), and [soy.iko.opencode.ui.components.RemoteImage]
10+ * (resolving image base URLs). Previously each call site carried its own private copy
11+ * with a "must be kept in sync" comment; the helpers here are the single source of truth.
12+ */
13+internal object CertPinUtil {
14+
15+ /** Split a certificate-pin field into individual OkHttp pins. Accepts whitespace- or
16+ * comma-separated "sha256/<base64>" entries; blank entries are dropped. */
17+ fun parseCertPins(raw: String?): List<String> =
18+ raw?.split(Regex("[\\s,]+"))?.map { it.trim() }?.filter { it.isNotEmpty() } ?: emptyList()
19+
20+ /** The base URL after applying [ServerProfile.requireHttps]: a cleartext http:// URL is
21+ * upgraded to https:// so the "Require HTTPS" choice is enforced at the transport layer.
22+ * A configured certificate pin also forces TLS: a pin is meaningless over cleartext and
23+ * would otherwise be silently dropped, so we honor the opt-in security control by upgrading
24+ * rather than connecting unpinned in the clear. Relies on [HttpClientFactory.normalizeBaseUrl]
25+ * for scheme/trailing-slash normalization. */
26+ fun effectiveBaseUrl(profile: ServerProfile): String {
27+ val normalized = HttpClientFactory.normalizeBaseUrl(profile.baseUrl)
28+ val forceHttps = profile.requireHttps || parseCertPins(profile.certPin).isNotEmpty()
29+ return if (forceHttps && normalized.lowercase().startsWith("http://")) {
30+ "https://" + normalized.substring("http://".length)
31+ } else {
32+ normalized
33+ }
34+ }
35+}
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.ktnew file+360
@@ -0,0 +1,360 @@
1+package soy.iko.opencode.data.network
2+
3+import soy.iko.opencode.data.model.BusEvent
4+import soy.iko.opencode.util.safeExceptionSummary
5+import android.util.Log
6+import io.ktor.client.HttpClient
7+import io.ktor.client.plugins.timeout
8+import io.ktor.client.plugins.sse.sse
9+import io.ktor.sse.ServerSentEvent
10+import kotlinx.coroutines.CancellationException
11+import kotlinx.coroutines.CoroutineScope
12+import kotlin.coroutines.EmptyCoroutineContext
13+import kotlinx.coroutines.channels.BufferOverflow
14+import kotlinx.coroutines.channels.Channel
15+import kotlinx.coroutines.channels.ChannelResult
16+import kotlinx.coroutines.flow.Flow
17+import kotlinx.coroutines.flow.MutableStateFlow
18+import kotlinx.coroutines.flow.SharedFlow
19+import kotlinx.coroutines.flow.StateFlow
20+import kotlinx.coroutines.flow.SharingStarted
21+import kotlinx.coroutines.flow.asStateFlow
22+import kotlinx.coroutines.flow.buffer
23+import kotlinx.coroutines.flow.channelFlow
24+import kotlinx.coroutines.flow.shareIn
25+import kotlinx.coroutines.isActive
26+import kotlinx.coroutines.launch
27+import kotlinx.coroutines.selects.onTimeout
28+import kotlinx.coroutines.selects.select
29+import java.io.IOException
30+import kotlin.random.Random
31+
32+/**
33+ * Owns the single long-lived `GET /event` SSE subscription and exposes it as a hot
34+ * [SharedFlow] of [BusEvent]. The connection is opened when the first collector
35+ * subscribes (WhileSubscribed) and reconnects with backoff on drop.
36+ *
37+ * Ordering contract: callers that send a prompt must already be collecting [events]
38+ * (directly or via the repository) so early `message.part.updated` deltas aren't missed.
39+ */
40+@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
41+open class EventStreamClient(
42+ private val client: HttpClient,
43+ private val scope: CoroutineScope,
44+ private val idleTimeoutMs: Long = NetworkConfig.sseIdleTimeoutMs,
45+ private val initialBackoffMs: Long = NetworkConfig.sseInitialBackoffMs,
46+ private val maxBackoffMs: Long = NetworkConfig.sseMaxBackoffMs,
47+ private val bufferCapacity: Int = NetworkConfig.sseEventBufferCapacity,
48+) {
49+ protected constructor() : this(
50+ HttpClient(io.ktor.client.engine.okhttp.OkHttp) {},
51+ CoroutineScope(EmptyCoroutineContext),
52+ )
53+ enum class ConnectionState { Disconnected, Connecting, Connected, Failed, AuthFailed }
54+
55+ private val _state = MutableStateFlow(ConnectionState.Disconnected)
56+ open val state: StateFlow<ConnectionState> = _state.asStateFlow()
57+
58+ /** Number of reconnect attempts made since the last successful [ConnectionState.Connected]
59+ * period. Incremented each time the stream loop begins a new connection attempt after a
60+ * drop, and reset to 0 on a successful connect. Surfaced so the UI can show "Reconnecting
61+ * (attempt N)…" during a sustained outage, giving the user confidence the system is
62+ * actively retrying rather than stuck. */
63+ private val _reconnectAttempts = MutableStateFlow(0)
64+ open val reconnectAttempts: StateFlow<Int> = _reconnectAttempts.asStateFlow()
65+
66+ open val events: SharedFlow<BusEvent> by lazy {
67+ stream()
68+ .buffer(bufferCapacity, BufferOverflow.DROP_OLDEST)
69+ .shareIn(
70+ scope = scope,
71+ started = SharingStarted.WhileSubscribed(stopTimeoutMillis = NetworkConfig.stateFlowSubscriptionTimeoutMs),
72+ replay = 0,
73+ )
74+ }
75+
76+ /** Conflated channel used to cut the reconnect backoff short when network returns. */
77+ private val reconnectSignal = Channel<Unit>(Channel.CONFLATED)
78+
79+ /** Classifies a non-retryable SSE failure so the UI can show the right recovery hint:
80+ * an auth failure (401/403) reads "check credentials", while other 4xx (400/404/405…)
81+ * reads "check the server URL and version". Returns null for transient/retryable errors.
82+ * SSL/TLS and certificate-pinning failures are permanent (a changed/rotated cert won't
83+ * heal on retry) and are classified as Other so the user sees "check the server URL and
84+ * version / cert pin" instead of an infinite "Reconnecting…" spinner. */
85+ private enum class SseFailureKind { Auth, Other }
86+
87+ private fun classifyNonRetryableSseFailure(e: Throwable): SseFailureKind? =
88+ generateSequence(e as Throwable?) { it.cause.takeIf { c -> c !== it } }
89+ .take(16)
90+ .mapNotNull { t ->
91+ when (t) {
92+ is io.ktor.client.plugins.sse.SSEClientException -> t.response?.status?.value
93+ is io.ktor.client.plugins.ClientRequestException -> t.response.status.value
94+ // A TLS handshake / cert-pinning / SSL peer unverified failure happens
95+ // before any HTTP response, so it carries no status code. It is never
96+ // transient (the server's cert must change), so classify it directly
97+ // instead of falling through to the null (retryable) default — otherwise
98+ // the client retries forever with backoff against an unreachable pinned
99+ // server, showing "Reconnecting…" with no hint that the failure is fixed.
100+ is javax.net.ssl.SSLException -> -1
101+ is java.security.cert.CertificateException -> -1
102+ else -> null
103+ }
104+ }
105+ // Non-408/429 4xx = permanent; 408 and 429 stay in the transient retry path.
106+ // The -1 sentinel (SSL/cert) is also permanent → Other.
107+ .map { code -> when {
108+ code == -1 -> SseFailureKind.Other
109+ code in 400..499 && code != 408 && code != 429 ->
110+ if (code == 401 || code == 403) SseFailureKind.Auth else SseFailureKind.Other
111+ else -> null
112+ } }
113+ .firstOrNull { it != null }
114+
115+ /** Request an immediate reconnect, skipping any in-progress backoff. */
116+ open fun triggerReconnect() { reconnectSignal.trySend(Unit) }
117+
118+ /**
119+ * Read SSE events from [events], racing each read against an idle-timeout watchdog.
120+ * Throws [IOException] on idle timeout so the outer reconnect loop fires; breaks on
121+ * clean stream close or scope cancellation.
122+ *
123+ * If the idle watchdog tripped because a [triggerReconnect] signal was consumed
124+ * (rather than a genuine idle timeout), [onReconnectConsumed] is invoked so the outer
125+ * reconnect loop can reconnect immediately instead of waiting out the backoff — the
126+ * signal is already consumed, so the backoff `select` couldn't be cut short.
127+ */
128+ private suspend fun readSseEvents(
129+ scope: CoroutineScope,
130+ events: kotlinx.coroutines.channels.ReceiveChannel<ServerSentEvent>,
131+ onReconnectConsumed: () -> Unit,
132+ send: suspend (BusEvent) -> Unit,
133+ ) {
134+ while (scope.isActive) {
135+ // The OkHttp engine has readTimeout=0 (the SSE stream must never time out on
136+ // read), so a socket the peer never closed would otherwise hang until OS TCP
137+ // keepalive kicks in — minutes to hours on stock Linux. If nothing arrives
138+ // within the idle timeout, treat the connection as half-open and drop it.
139+ var consumedReconnectSignal = false
140+ val result: ChannelResult<ServerSentEvent>? = select {
141+ events.onReceiveCatching { it }
142+ onTimeout(idleTimeoutMs) { null }
143+ // A triggerReconnect() issued while a read is active must interrupt it now:
144+ // return the same null sentinel as the idle timeout so a half-open socket
145+ // (no TCP RST) is dropped and reconnected promptly instead of waiting out
146+ // idleTimeoutMs (~90s).
147+ reconnectSignal.onReceive { consumedReconnectSignal = true; null }
148+ }
149+ if (result == null) {
150+ if (consumedReconnectSignal) onReconnectConsumed()
151+ throw IOException("SSE read dropped (idle timeout or reconnect requested), reconnecting")
152+ }
153+ val sse = result.getOrNull()
154+ if (sse == null) {
155+ // The producer channel is closed. A non-null cause means `incoming`
156+ // failed (e.g. a socket reset bridged from the producer) — rethrow it so
157+ // the outer loop logs and retries with backoff. A clean close (null
158+ // cause) just ends this attempt and lets the loop reconnect.
159+ result.exceptionOrNull()?.let { throw it }
160+ break
161+ }
162+ val data = sse.data
163+ if (data == null) {
164+ // A comment or keep-alive event (no data field) resets the idle watchdog
165+ // — servers send `: ping` comments to keep the connection alive through
166+ // proxies. Without this, a server that only sends comments would still
167+ // hit the idle timeout and reconnect unnecessarily.
168+ continue
169+ }
170+ val event = try {
171+ OpencodeJson.decodeFromString(BusEvent.serializer(), data)
172+ } catch (e: kotlinx.serialization.SerializationException) {
173+ Log.d("EventStream", "Skipping unparseable SSE event", e)
174+ continue
175+ }
176+ if (_state.value != ConnectionState.Connected) {
177+ _state.value = ConnectionState.Connected
178+ }
179+ send(event)
180+ }
181+ }
182+
183+ private fun stream(): Flow<BusEvent> = channelFlow {
184+ val scope = this
185+ // Drain any stale reconnect signal from a previous collection cycle so it
186+ // doesn't spuriously reset the backoff on startup (WhileSubscribed may have
187+ // stopped the upstream while triggerReconnect() queued a signal).
188+ while (reconnectSignal.tryReceive().isSuccess) { /* drain */ }
189+ var backoffMs = initialBackoffMs
190+ // Tracks whether this is the first iteration (the initial connect) vs a reconnect,
191+ // so the attempt counter is only incremented on reconnects (not the first connect).
192+ var firstAttempt = true
193+ while (isActive) {
194+ // Increment the reconnect-attempt counter on every iteration except the initial
195+ // connect, so the UI can show "Reconnecting (attempt N)…". Reset to 0 on success.
196+ if (firstAttempt) firstAttempt = false else _reconnectAttempts.value++
197+ _state.value = ConnectionState.Connecting
198+ // Set by readSseEvents when a triggerReconnect() signal interrupted an active
199+ // read. The signal is consumed to drop the socket, so the backoff select below
200+ // can't also see it — without this flag the reconnect would wait the full
201+ // backoff despite the caller asking for an immediate reconnect (network
202+ // handoff, manual refresh, ...).
203+ var reconnectConsumedDuringRead = false
204+ try {
205+ // The SSE stream is long-lived: disable the request-level and socket
206+ // timeouts (the client default is 60s for REST) in the request config
207+ // so the connection isn't killed mid-stream. The idle-timeout watchdog
208+ // below handles half-open connections instead.
209+ client.sse(
210+ "event",
211+ request = {
212+ timeout {
213+ requestTimeoutMillis = Long.MAX_VALUE
214+ // Disable the socket (read) timeout entirely for the SSE stream.
215+ // A finite value would kill the connection whenever the server
216+ // is quiet for longer than that (idle sessions, slow agent runs),
217+ // causing a reconnect storm. Half-open detection is handled by
218+ // the idle-timeout watchdog below plus OkHttp's pingInterval on
219+ // HTTP/2 connections.
220+ socketTimeoutMillis = Long.MAX_VALUE
221+ }
222+ },
223+ ) {
224+ // The SSE block is open — the connection is established. Mark as
225+ // Connected immediately so downstream re-seed logic fires even when
226+ // the server only sends keep-alive comments (no data events).
227+ if (_state.value != ConnectionState.Connected) {
228+ _state.value = ConnectionState.Connected
229+ }
230+ // Clear any stale reconnect signal from a triggerReconnect() call
231+ // made while the previous stream was healthy, so it doesn't suppress
232+ // the next legitimate backoff.
233+ while (reconnectSignal.tryReceive().isSuccess) { /* drain */ }
234+ // Bridge ktor's `incoming` flow to a channel so each element can be
235+ // raced against the idle-timeout watchdog via select. We collect it
236+ // ourselves instead of `incoming.produceIn(scope)`: produceIn launches
237+ // the collector as a child of this channelFlow, so when `incoming`
238+ // throws on an abnormal socket close — a TCP reset, i.e. the common
239+ // mobile case: Wi-Fi⇄cellular handoff, server or proxy RST — the child's
240+ // failure cancels the whole channelFlow and bypasses the retry `catch`
241+ // below. That killed reconnect outright and surfaced the IOException as
242+ // an uncaught crash through the (handler-less) shareIn scope. Collecting
243+ // into our own channel and closing it with the cause keeps the failure
244+ // local: readSseEvents sees the closed channel, rethrows the cause, and
245+ // the normal retry/backoff path handles it. (A clean EOF closes the
246+ // channel with no cause → readSseEvents breaks and reconnects as before.)
247+ val events = Channel<ServerSentEvent>(Channel.BUFFERED)
248+ val producer = scope.launch {
249+ try {
250+ incoming.collect { events.send(it) }
251+ events.close()
252+ } catch (c: CancellationException) {
253+ events.close(c)
254+ throw c
255+ } catch (t: Throwable) {
256+ // Deliver the failure via the channel rather than letting it
257+ // propagate up and cancel the parent channelFlow.
258+ events.close(t)
259+ }
260+ }
261+ // Reset the backoff only once real data actually arrives, not on
262+ // block entry: a server that accepts the connection then immediately
263+ // drops it would otherwise loop forever at the initial backoff and
264+ // never escalate the exponential ramp.
265+ var receivedAny = false
266+ try {
267+ readSseEvents(scope, events, { reconnectConsumedDuringRead = true }) {
268+ if (!receivedAny) {
269+ receivedAny = true
270+ backoffMs = initialBackoffMs
271+ // A genuine event confirms the connection is healthy, so reset
272+ // the reconnect-attempt counter for the next outage's count.
273+ _reconnectAttempts.value = 0
274+ }
275+ send(it)
276+ }
277+ } finally {
278+ producer.cancel()
279+ events.cancel()
280+ }
281+ }
282+ } catch (c: CancellationException) {
283+ _state.value = ConnectionState.Disconnected
284+ throw c
285+ } catch (e: Exception) {
286+ // If the scope was cancelled (e.g. connection close), the closed-client
287+ // exception is expected — don't log a spurious "stream error" warning.
288+ val failureKind = classifyNonRetryableSseFailure(e)
289+ if (failureKind != null) {
290+ // The server rejected the request with a non-retryable 4xx (bad
291+ // credentials, missing endpoint, ...) — retrying won't help. Log a
292+ // scrubbed summary (class + status) instead of the full exception, whose
293+ // message carries the request URL and may include auth or paths.
294+ Log.w("EventStream", "SSE non-retryable 4xx (${if (failureKind == SseFailureKind.Auth) "auth" else "endpoint"}), awaiting explicit reconnect: ${safeExceptionSummary(e)}")
295+ _state.value = if (failureKind == SseFailureKind.Auth) ConnectionState.AuthFailed else ConnectionState.Failed
296+ if (!isActive) {
297+ // Final teardown (last subscriber left) while a 4xx surfaced: end in
298+ // Disconnected like every other loop-exit path. Failed must stay
299+ // observable only while the flow is live/retrying, not after teardown.
300+ _state.value = ConnectionState.Disconnected
301+ break
302+ }
303+ // Don't complete the flow: a subscriber may fix the profile and call
304+ // triggerReconnect(). Suspend until an explicit reconnect signal
305+ // arrives (no backoff delay), then re-attempt the connection. This receive
306+ // is outside the try above, so reset state to Disconnected if the last
307+ // subscriber leaves (cancellation) while we're parked here — otherwise the
308+ // stream would be torn down with state stuck at Failed.
309+ try {
310+ reconnectSignal.receive()
311+ } catch (c: CancellationException) {
312+ _state.value = ConnectionState.Disconnected
313+ throw c
314+ }
315+ // Reset the reconnect-attempt counter: the user explicitly triggered this
316+ // reconnect (fixed credentials, manual refresh, ...), so it's a fresh
317+ // attempt — not a continuation of the pre-failure outage. Without this,
318+ // firstAttempt is already false and line 196 increments the counter on
319+ // every manual reconnect, growing "Reconnecting (attempt N)" unboundedly
320+ // across repeated 4xx → fix → retry cycles.
321+ _reconnectAttempts.value = 0
322+ continue
323+ } else if (isActive) {
324+ Log.w("EventStream", "SSE stream error, will retry: ${safeExceptionSummary(e)}")
325+ }
326+ }
327+ _state.value = ConnectionState.Disconnected
328+ if (!isActive) break
329+ // Wait for the backoff, but allow a reconnect signal to cut it short. When
330+ // readSseEvents consumed a triggerReconnect() signal to drop an active read,
331+ // the signal is already spent so the select below can't receive it — pass a
332+ // zero backoff in that case so the reconnect is immediate (the whole point of
333+ // triggerReconnect: network handoff, manual refresh, ...).
334+ val signaled = waitForBackoffOrSignal(reconnectConsumedDuringRead, backoffMs)
335+ backoffMs = if (signaled) initialBackoffMs else (backoffMs * 2).coerceAtMost(maxBackoffMs)
336+ }
337+ }
338+
339+ /** Suspend for the backoff unless [reconnectConsumedDuringRead] (immediate) or a new
340+ * [triggerReconnect] arrives during the wait. Returns true when the reconnect should
341+ * skip the backoff ramp (signal-driven), false when the full backoff elapsed. */
342+ private suspend fun waitForBackoffOrSignal(
343+ reconnectConsumedDuringRead: Boolean,
344+ backoffMs: Long,
345+ ): Boolean {
346+ if (reconnectConsumedDuringRead) return true
347+ var signaled = false
348+ val jitter = ((backoffMs * NetworkConfig.retryJitterFactor) * (Random.nextDouble() * 2 - 1)).toLong()
349+ // Floor the wait at half the initial backoff: symmetric jitter can otherwise pull a
350+ // small backoff down to 0ms, producing a sub-second reconnect storm right after a
351+ // triggerReconnect reset on a connect-RST loop. A non-zero floor preserves the
352+ // purpose of backoff (spacing retries) while still allowing jitter to shorten it.
353+ val minWait = (initialBackoffMs / 2).coerceAtLeast(1L)
354+ select {
355+ reconnectSignal.onReceive { signaled = true }
356+ onTimeout((backoffMs + jitter).coerceAtLeast(minWait)) { /* normal backoff elapsed */ }
357+ }
358+ return signaled
359+ }
360+}
app/src/main/java/soy/iko/opencode/data/network/HttpClientFactory.ktnew file+178
@@ -0,0 +1,178 @@
1+package soy.iko.opencode.data.network
2+
3+import soy.iko.opencode.data.model.ServerProfile
4+import io.ktor.client.HttpClient
5+import io.ktor.client.engine.okhttp.OkHttp
6+import io.ktor.client.plugins.HttpTimeout
7+import io.ktor.client.plugins.api.Send
8+import io.ktor.client.plugins.api.createClientPlugin
9+import io.ktor.client.plugins.auth.Auth
10+import io.ktor.client.plugins.auth.providers.BasicAuthCredentials
11+import io.ktor.client.plugins.auth.providers.basic
12+import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
13+import io.ktor.client.plugins.defaultRequest
14+import io.ktor.client.plugins.sse.SSE
15+import io.ktor.http.HttpHeaders
16+import io.ktor.http.URLBuilder
17+import io.ktor.http.takeFrom
18+import io.ktor.serialization.kotlinx.json.json
19+import okhttp3.CertificatePinner
20+import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
21+import java.util.concurrent.TimeUnit
22+
23+/**
24+ * Builds a Ktor client bound to a single [ServerProfile]. One client per active
25+ * profile: it carries the base URL, optional Basic auth, JSON negotiation, and the
26+ * SSE plugin used for `GET /event`.
27+ */
28+object HttpClientFactory {
29+
30+ fun create(profile: ServerProfile): HttpClient {
31+ val normalizedUrl = CertPinUtil.effectiveBaseUrl(profile)
32+ val isHttps = normalizedUrl.lowercase().startsWith("https://")
33+ // Parse the host with OkHttp's own parser (not java.net.URI, which rejects hosts OkHttp
34+ // accepts, e.g. underscores in "my_server.local"). If pinning is configured over HTTPS
35+ // but the host can't be parsed, fail closed: never build an unpinned client — that would
36+ // be a silent security downgrade for a user who opted into pinning.
37+ val pinHost = normalizedUrl.toHttpUrlOrNull()?.host
38+ val pins = CertPinUtil.parseCertPins(profile.certPin)
39+ if (pins.isNotEmpty() && isHttps && pinHost == null) {
40+ throw IllegalStateException(
41+ "Certificate pinning is configured but the server host could not be parsed from $normalizedUrl",
42+ )
43+ }
44+ // Fail closed for HTTP+auth with an unparseable host too: otherwise neither the HTTPS Auth
45+ // plugin nor the HTTP host-scoped header plugin is installed (the HTTP branch below is
46+ // gated on pinHost != null), so credentials would be configured but never transmitted —
47+ // hasAuth reads true in the UI while every request goes out unauthenticated. An unparseable
48+ // host generally can't connect anyway, but failing here surfaces a clear error instead of a
49+ // silent auth bypass.
50+ if (profile.hasAuth && !isHttps && pinHost == null) {
51+ throw IllegalStateException(
52+ "Auth is configured but the server host could not be parsed from $normalizedUrl",
53+ )
54+ }
55+
56+ return HttpClient(OkHttp) {
57+ expectSuccess = true
58+
59+ engine {
60+ config {
61+ // A normal read timeout for REST calls. The SSE stream overrides this
62+ // to infinite inside the sse {} request block (see EventStreamClient),
63+ // so the long-lived event connection is never killed mid-stream.
64+ readTimeout(NetworkConfig.readTimeoutSeconds, TimeUnit.SECONDS)
65+ connectTimeout(NetworkConfig.connectTimeoutSeconds, TimeUnit.SECONDS)
66+ retryOnConnectionFailure(true)
67+ pingInterval(NetworkConfig.pingIntervalSeconds, TimeUnit.SECONDS)
68+ // Pin the server certificate(s) when configured and connecting over TLS.
69+ // A malformed pin throws here (surfaced as a connection failure), which is
70+ // the right fail-closed behavior for a security control the user opted into.
71+ if (pins.isNotEmpty() && pinHost != null && isHttps) {
72+ certificatePinner(
73+ CertificatePinner.Builder().apply { pins.forEach { add(pinHost, it) } }.build(),
74+ )
75+ }
76+ }
77+ }
78+
79+ install(ContentNegotiation) {
80+ json(OpencodeJson)
81+ }
82+
83+ install(SSE)
84+
85+ // Request-level timeout for REST calls. The SSE stream overrides this to
86+ // infinite inside the sse {} request block (see EventStreamClient) so the
87+ // long-lived /event connection isn't killed after 60s. The engine's connect
88+ // timeout and the SSE idle-timeout watchdog handle stuck SSE connections.
89+ install(HttpTimeout) {
90+ requestTimeoutMillis = NetworkConfig.restRequestTimeoutMs
91+ }
92+
93+ if (profile.hasAuth) {
94+ if (isHttps) {
95+ install(Auth) {
96+ basic {
97+ credentials {
98+ BasicAuthCredentials(
99+ username = profile.username.orEmpty(),
100+ password = profile.password.orEmpty(),
101+ )
102+ }
103+ // Send eagerly so opencode doesn't need a 401 challenge round-trip, but
104+ // only to the configured host: a cross-host redirect must not carry the
105+ // credentials off-origin (the plugin would still answer a genuine 401 from
106+ // the real host reactively). The Auth plugin is only installed for HTTPS
107+ // profiles — over HTTP, even reactive credential sending would leak
108+ // passwords in cleartext, so we don't install it at all.
109+ sendWithoutRequest { request ->
110+ pinHost != null && request.url.host.equals(pinHost, ignoreCase = true)
111+ }
112+ }
113+ }
114+ // Defense in depth mirroring the HTTP branch below: sendWithoutRequest only gates
115+ // *eager* attachment on the outgoing request — it neither strips an Authorization
116+ // header that HttpRedirect copies from the original (configured-host) request onto
117+ // a cross-origin redirect target, nor stops the reactive Basic provider answering a
118+ // 401 from such a target. followRedirects is enabled by default, so re-check the
119+ // host on every physical send and remove the header whenever it isn't the pinned
120+ // host, guaranteeing credentials never leave the configured origin.
121+ install(
122+ createClientPlugin("HttpsBasicAuthHostScoped") {
123+ on(Send) { request ->
124+ if (pinHost == null || !request.url.host.equals(pinHost, ignoreCase = true)) {
125+ request.headers.remove(HttpHeaders.Authorization)
126+ }
127+ proceed(request)
128+ }
129+ },
130+ )
131+ } else if (pinHost != null) {
132+ // For HTTP profiles with auth, attach the Basic header proactively (the reactive
133+ // Auth plugin is skipped for non-HTTPS so a 401 can't silently re-send credentials
134+ // in cleartext), but scope it to the configured host and re-check on EVERY physical
135+ // send. Redirects matter: Ktor builds the redirect request by copying the original
136+ // headers, so an unconditional header would carry the cleartext credentials to a
137+ // cross-host redirect target. Setting it here (and stripping it off-host) mirrors
138+ // the HTTPS path's sendWithoutRequest host guard so credentials never go off-origin.
139+ val credentials = java.util.Base64.getEncoder().encodeToString(
140+ "${profile.username.orEmpty()}:${profile.password.orEmpty()}".toByteArray(),
141+ )
142+ install(
143+ createClientPlugin("HttpBasicAuthHostScoped") {
144+ on(Send) { request ->
145+ if (request.url.host.equals(pinHost, ignoreCase = true)) {
146+ request.headers[HttpHeaders.Authorization] = "Basic $credentials"
147+ } else {
148+ request.headers.remove(HttpHeaders.Authorization)
149+ }
150+ proceed(request)
151+ }
152+ },
153+ )
154+ }
155+ }
156+
157+ defaultRequest {
158+ url.takeFrom(URLBuilder().takeFrom(normalizedUrl))
159+ }
160+ }
161+ }
162+
163+ /** Ensure a scheme and a single trailing slash so relative paths resolve correctly.
164+ * Defaults to [https] when no scheme is given so credentials aren't accidentally
165+ * sent over cleartext. Users who need plain HTTP must type the scheme explicitly. */
166+ fun normalizeBaseUrl(raw: String): String {
167+ val trimmed = raw.trim()
168+ // Compare the scheme prefix case-insensitively (a lowercased copy) while
169+ // preserving the original casing of the rest of the URL in the output.
170+ val lowerTrimmed = trimmed.lowercase()
171+ val withScheme = if (lowerTrimmed.startsWith("http://") || lowerTrimmed.startsWith("https://")) {
172+ trimmed
173+ } else {
174+ "https://$trimmed"
175+ }
176+ return if (withScheme.endsWith("/")) withScheme else "$withScheme/"
177+ }
178+}
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.ktnew file+449
@@ -0,0 +1,449 @@
1+package soy.iko.opencode.data.network
2+
3+/**
4+ * Centralized tuning parameters for the network and presentation layers. Keeping these
5+ * in one place makes them easy to find and adjust without hunting through source files,
6+ * and makes the intent of each value explicit rather than a bare magic number.
7+ */
8+object NetworkConfig {
9+
10+ // --- OkHttp engine timeouts (HttpClientFactory) ---
11+
12+ /** Socket connect timeout for all HTTP calls, including the SSE handshake. */
13+ const val connectTimeoutSeconds = 30L
14+ /** Socket read timeout for ordinary REST calls. The SSE stream overrides this to INFINITE. */
15+ const val readTimeoutSeconds = 60L
16+ /** OkHttp WebSocket/interval ping, keeping the SSE socket alive through proxies. */
17+ const val pingIntervalSeconds = 20L
18+ /** Request-level timeout for REST calls (covers connect + headers + body). The SSE stream overrides this to INFINITE. */
19+ const val restRequestTimeoutMs = 60_000L
20+
21+ // --- REST retry (OpencodeApiClient.withRetry) ---
22+
23+ /** Maximum number of attempts for a retriable REST call. */
24+ const val retryMaxAttempts = 3
25+ /** Initial backoff delay; doubles on each successive failure (exponential backoff). */
26+ const val retryInitialDelayMs = 500L
27+ /** Cap on a single retry backoff delay, guarding against overflow at high attempt counts. */
28+ const val retryMaxDelayMs = 30_000L
29+ /** Jitter factor for retry backoff (0.2 = ±20% symmetric random jitter on each delay). */
30+ const val retryJitterFactor = 0.2
31+
32+ // --- SSE stream (EventStreamClient) ---
33+
34+ /** Initial reconnect backoff after an SSE stream drop; doubles up to [sseMaxBackoffMs]. */
35+ const val sseInitialBackoffMs = 500L
36+ /** Cap on the SSE reconnect backoff. */
37+ const val sseMaxBackoffMs = 10_000L
38+ /** Reconnect-attempt count at which the connection banner switches from "Reconnecting…"
39+ * to "Reconnecting (attempt N)…" — after a few retries the user benefits from seeing the
40+ * system is actively retrying rather than stuck. Below this the plain label is calmer. */
41+ const val sseReconnectAttemptLabelThreshold = 3
42+ /** Max gap between events before a silent/half-open SSE connection is dropped and reconnected. */
43+ const val sseIdleTimeoutMs = 90_000L
44+ /** Buffer capacity for the SSE events SharedFlow; prevents a slow subscriber stalling the read loop. */
45+ const val sseEventBufferCapacity = 64
46+
47+ // --- Connectivity monitor (AppContainer) ---
48+
49+ /** Grace period after a network is lost before reporting offline. Absorbs the transient
50+ * window during a Wi-Fi→cellular handoff where onLost(WiFi) fires before the replacement
51+ * network becomes active (cm.activeNetwork is briefly null), so a handoff doesn't flash
52+ * offline while a genuine loss (no active network after the grace) is still caught. */
53+ const val networkOfflineGraceMs = 2_000L
54+
55+ // --- Catalog cache (OpencodeApiClient) ---
56+
57+ /** How long cached catalog responses (providers/agents/commands) are considered fresh. */
58+ const val catalogCacheTtlMs = 60_000L
59+
60+ /** TTL for files written to the cache dir to hand to external apps via FileProvider
61+ * (open-externally). Stale files past this age are pruned on the next open so repeated
62+ * opens of large files don't accumulate unbounded. 24h covers a typical session. */
63+ const val externalCacheTtlMs = 24L * 60 * 60 * 1000
64+
65+ // --- Session list (SessionListViewModel) ---
66+
67+ /** Max sessions to fetch previews for in one batch. */
68+ const val maxPreviewSessions = 50
69+ /** Initial render window for the session list — the number of rows composed before the
70+ * user scrolls to reveal more. Grows by [sessionListPageStep] as the user nears the
71+ * bottom, bounding the sort/filter work on a huge list while keeping scrolling seamless. */
72+ const val sessionListInitialPage = 60
73+ /** How many additional rows to reveal when the session list render window is exhausted. */
74+ const val sessionListPageStep = 60
75+ /** Initial render window for the file-browser directory listing. A huge directory
76+ * (10k+ entries) sorts/filters in full on each recomposition; windowing the render
77+ * bounds the composed-row count while the sort still runs over the full list (so the
78+ * order is correct, just the visible slice is capped). Grows as the user scrolls. */
79+ const val fileListInitialPage = 100
80+ /** How many additional file rows to reveal when the render window is exhausted. */
81+ const val fileListPageStep = 100
82+ /** Max concurrent preview fetches (prevents flooding the server with parallel requests). */
83+ const val maxConcurrentPreviews = 8
84+ /** Max characters of a session's last message to keep as a list preview. */
85+ const val previewTextMaxLength = 200
86+ /** Debounce delay before fetching a session preview after an SSE update, so a burst
87+ * of SessionUpdated events (e.g. during active streaming) coalesces into one fetch
88+ * instead of launching one per event (each downloading the full message history). */
89+ const val previewDebounceMs = 500L
90+ /** Debounce for applying SessionUpdated SSE events to the session list state. During
91+ * active streaming the server emits these frequently; without debouncing each one
92+ * triggers a full list filter + sort + recomposition, causing scroll jank. */
93+ const val sessionUpdateDebounceMs = 300L
94+
95+ // --- Global search (GlobalSearchViewModel) ---
96+
97+ /** Max sessions whose message history is scanned in one global-search pass. Global
98+ * search downloads each session's messages, so this bounds the work; if there are more
99+ * sessions, the result set notes that the search was truncated. */
100+ const val maxSearchSessions = 50
101+ /** Debounce before a global search fires, so typing doesn't launch a fetch per keystroke. */
102+ const val searchDebounceMs = 350L
103+ /** Minimum query length before a global search runs (shorter queries match too much). */
104+ const val minSearchQueryLength = 2
105+ /** Characters of context to show around a global-search match. */
106+ const val searchSnippetLength = 160
107+
108+ // --- Message cache (MessageCacheStore) ---
109+
110+ /** Minimum interval between on-disk writes of a session's message snapshot, so a fast
111+ * token stream doesn't hammer the disk. The cache only needs to be recent enough for an
112+ * instant/offline first paint; the network corrects it on the next open. */
113+ const val messageCacheWriteThrottleMs = 2_000L
114+
115+ /** Max entries in MessageCacheStore's tombstone set. Tombstones only need to survive long
116+ * enough to block a racing teardown flush (which resolves within seconds of deletion), so
117+ * entries from long-ago deletions are useless. Capping prevents unbounded growth across
118+ * many session deletions over the app's lifetime; when exceeded the set is cleared wholesale
119+ * (coarse but safe — no racing flush can still be alive for a session deleted sessions ago). */
120+ const val maxMessageCacheTombstones = 128
121+
122+ /** Grace period before a deferred session delete actually fires, during which an
123+ * Undo snackbar lets the user cancel it. */
124+ const val undoDeleteDelayMs = 5_000L
125+
126+ /** Grace period before a deferred server-profile delete actually fires, during which
127+ * an Undo snackbar lets the user cancel it. Same UX rationale as [undoDeleteDelayMs]. */
128+ const val undoServerDeleteDelayMs = 5_000L
129+
130+ /** Grace period before a deferred crash-report delete actually fires, during which
131+ * an Undo snackbar lets the user cancel it. */
132+ const val undoReportDeleteDelayMs = 5_000L
133+
134+ // --- In-memory message store (SessionRepository.MessageStore) ---
135+
136+ /** Max messages to keep in memory per observed session; oldest are evicted beyond this. */
137+ const val maxInMemoryMessages = 500
138+
139+ // --- File viewer (FileViewScreen) ---
140+
141+ /** Cap on how many lines the raw viewer renders and searches. A multi-megabyte file is
142+ * split once (not twice) into this many lines at most, so it can't hold two full line
143+ * lists in memory or stall the LazyColumn; beyond the cap a truncation banner is shown. */
144+ const val maxRenderedFileLines = 5000
145+
146+ // --- File browser (FileBrowserViewModel) ---
147+
148+ /** Debounce delay before firing a file-search request after the user stops typing. */
149+ const val fileSearchDebounceMs = 250L
150+
151+ /** Debounce delay before re-running the in-conversation message search after the user
152+ * stops typing. The search walks every part of every message (including stringifying
153+ * tool state), so debouncing avoids jank on long conversations. The filter itself runs
154+ * off the main thread; this delay coalesces rapid keystrokes. */
155+ const val chatSearchDebounceMs = 200L
156+
157+ // --- Attachments (chat composer) ---
158+
159+ /** Max size of a single attachment (image/file) before it's rejected. Base64 inflates
160+ * the payload ~33%, and the whole prompt body is held in memory, so cap generously but
161+ * finitely to avoid OOM / oversized requests. */
162+ const val maxAttachmentBytes = 10L * 1024 * 1024
163+
164+ /** Max number of attachments staged for one prompt, to bound memory and request size. */
165+ const val maxAttachments = 8
166+
167+ /** Max cumulative size of all attachments staged for one prompt. The per-file
168+ * [maxAttachmentBytes] cap alone allows [maxAttachments] max-size files (~80MB raw,
169+ * ~106MB as base64) to pile up in memory and be re-serialized on every add/remove and
170+ * again when the request body is built — a realistic OOM. This bounds the whole set. */
171+ const val maxTotalAttachmentBytes = 25L * 1024 * 1024
172+
173+ // --- StateFlow sharing (WhileSubscribed) ---
174+
175+ /** Grace period before a cold StateFlow is stopped after its last subscriber leaves. */
176+ const val stateFlowSubscriptionTimeoutMs = 5_000L
177+
178+ /** Maximum time a notification action BroadcastReceiver may hold its goAsync()
179+ * PendingResult open. The system ANRs a receiver that doesn't call finish() within
180+ * ~10s, so this must stay below that; a slow permission-respond retry (withRetry
181+ * exponential backoff up to ~90s) or a blocked outbox enqueue could otherwise blow
182+ * past the window. The watchdog finishes the PendingResult at this deadline so the
183+ * receiver never ANRs; the underlying network call keeps running on the app scope. */
184+ const val notificationReceiverTimeoutMs = 8_000L
185+
186+ // --- Draft persistence (ChatViewModel) ---
187+
188+ /** Debounce delay before persisting a draft to disk after the user stops typing. */
189+ const val draftDebounceMs = 500L
190+
191+ /** Maximum characters allowed in the chat input field. A generous cap that prevents
192+ * a huge paste from stalling the UI (the field would otherwise buffer and lay out
193+ * an unbounded string) while leaving plenty of room for long prompts. */
194+ const val maxDraftLengthChars = 32_000
195+
196+ /** Show the "remaining characters" supportingText under the chat input once the
197+ * draft length crosses this fraction of [maxDraftLengthChars], so the cap is
198+ * visible before it silently kicks in but doesn't clutter a normal short prompt. */
199+ const val draftCountdownThresholdFraction = 0.8f
200+
201+ /** Maximum characters allowed in a session title (rename dialog). Prevents the
202+ * server from rejecting an overly long title and keeps list rows readable. */
203+ const val maxSessionTitleChars = 200
204+
205+ // --- Profile store (ServerEditViewModel) ---
206+
207+ /** Timeout for loading a profile from DataStore before giving up. */
208+ const val profileLoadTimeoutMs = 5_000L
209+
210+ /** Timeout for a credential test (probe) before giving up, so a hung/unresponsive server
211+ * doesn't leave the "Testing…" spinner spinning indefinitely. */
212+ const val testCredentialsTimeoutMs = 15_000L
213+
214+ // --- SSE stream (EventStreamClient, AppContainer) ---
215+
216+ /** Delay before retrying the message-activity observer after a failure. */
217+ const val observerRetryDelayMs = 5_000L
218+ /** Debounce before the run foreground-service is started/stopped in response to
219+ * run-activity changes. A run that starts and then fails/idles almost immediately
220+ * would otherwise dispatch startForegroundService() then stopService() back-to-back;
221+ * if the stop wins the race before startForeground() runs, Android raises the
222+ * "did not then call startForeground()" crash. Debouncing collapses that window.
223+ * Also serves as a minimum-run-duration gate: a run shorter than this (a quick abort,
224+ * a one-token reply) clears [anyRunActive] before the FGS ever posts, so the shade
225+ * stays clean of non-dismissable notifications for trivially short runs. The Doze-
226+ * resilience benefit only matters for long runs, so a few seconds of gate preserves
227+ * the core benefit. */
228+ const val runForegroundDebounceMs = 3_000L
229+
230+ // --- Markdown rendering (MarkdownText) ---
231+
232+ /** Throttle delay to coalesce streaming tokens into one re-parse. The markdown
233+ * library re-parses the full AST on every content change, so during a long streaming
234+ * response this is O(n²) work. 50ms (~20fps) is smooth for progressively appearing
235+ * text while cutting parse work ~3x versus a per-frame (16ms) throttle. */
236+ const val streamingThrottleMs = 50L
237+
238+ // --- Snackbar one-shot events (ViewModels) ---
239+
240+ /** Buffer capacity for transient error SharedFlows that drive snackbars. */
241+ const val snackbarEventBufferCapacity = 16
242+
243+ /** How long the chat top-bar refresh icon stays in its "refreshing" spinner state
244+ * after a tap, giving immediate feedback that the refresh was triggered even when
245+ * the connection state is already Connected (so the ConnectionBanner doesn't change). */
246+ const val refreshFeedbackMs = 1_200L
247+
248+ // --- UI layout constants ---
249+
250+ /** Minimum window width (dp) for two-pane layout on tablets / unfolded foldables. */
251+ const val twoPaneWidthThresholdDp = 840
252+ /** Maximum width fraction a user message bubble can occupy (keeps it readable). */
253+ const val userBubbleWidthFraction = 0.85f
254+ /** Minimum number of options in a picker sheet (model/agent/command) before its search
255+ * field auto-focuses and raises the keyboard on open. A tiny catalog is faster to scan by
256+ * eye, so popping the keyboard for it is just friction. */
257+ const val pickerSearchAutofocusThreshold = 8
258+
259+ /** Minimum number of saved server profiles before the server list shows its search/filter
260+ * field. A typical user has one or two servers, where scanning by eye beats typing; power
261+ * users juggling many profiles get the filter once it earns its space. */
262+ const val serverListSearchThreshold = 5
263+
264+ /** Minimum number of crash reports before the diagnostics list shows its search field. */
265+ const val diagnosticsSearchThreshold = 8
266+
267+ /** Initial render window for the diagnostics crash-report list — the number of rows
268+ * composed before the user scrolls to reveal more. Bounds composition work on a device
269+ * that has accumulated many crash reports. Grows by [diagnosticsListPageStep] as the
270+ * user nears the bottom. */
271+ const val diagnosticsListInitialPage = 60
272+ /** How many additional crash-report rows to reveal when the render window is exhausted. */
273+ const val diagnosticsListPageStep = 60
274+
275+ /** Minimum number of sessions in the usage report before the per-session list shows its
276+ * filter field. A short history is faster to scan than type into. */
277+ const val usageSessionSearchThreshold = 8
278+
279+ /** Minimum number of sessions before the session list shows its search/filter field.
280+ * Matches the sibling screens' convention: a short list is faster to scan by eye than
281+ * type into, so the field earns its vertical space only once the list grows. */
282+ const val sessionListSearchThreshold = 5
283+ /** Extra bottom content padding (dp) for the chat message list, reserving room for the
284+ * jump-to-latest FAB so it never floats over the last message. */
285+ const val chatListFabInsetDp = 80
286+
287+ /** Extra bottom content padding (dp) for single-pane list screens that host a FAB
288+ * (session list, server list, MCP), reserving room so the FAB never floats over the
289+ * last row. Unified across screens so the inset doesn't drift (the session list
290+ * previously hardcoded 96dp while the chat list used [chatListFabInsetDp]). */
291+ const val listFabInsetDp = 96
292+ /** Left pane weight in the two-pane layout (session list). */
293+ const val twoPaneLeftWeight = 0.38f
294+ /** Right pane weight in the two-pane layout (chat detail). */
295+ const val twoPaneRightWeight = 0.62f
296+
297+ /** Maximum width (dp) for the left (session-list) pane in the two-pane layout. On a wide
298+ * tablet the list pane would otherwise stretch too wide for a readable row density; capping
299+ * it keeps the list compact and gives the chat detail pane the remaining space. */
300+ const val twoPaneLeftMaxWidthDp = 460
301+
302+ /** Minimum window width (dp) at which a NavigationRail is shown alongside the
303+ * two-pane layout for top-level destination discoverability on large screens. Sits below
304+ * [twoPaneWidthThresholdDp] (M3's WindowWidthSizeClass.Medium breakpoint ~600dp) so a
305+ * tablet or landscape phone gets the rail while staying single-pane until 840dp, where the
306+ * two-pane master/detail split takes over. */
307+ const val navigationRailThresholdDp = 600
308+
309+ /** Maximum width (dp) for the chat message list on large screens. On a tablet or
310+ * unfolded foldable, full-width message bubbles stretch edge-to-edge, hurting
311+ * readability — capping and centering the list (like Gmail/Telegram on tablets)
312+ * keeps line lengths comfortable. Applied only above [twoPaneWidthThresholdDp]. */
313+ const val chatContentMaxWidthDp = 800
314+
315+ /** Maximum width (dp) for a single-pane list/form screen on large screens (Diagnostics,
316+ * MCP, Usage, Global Search, File Browser). Mirrors [chatContentMaxWidthDp]'s rationale
317+ * at a tighter width suited to dense list rows and form fields. Settings and ServerEdit
318+ * use 600dp directly; this constant centralizes the same value for the screens that
319+ * previously stretched edge-to-edge on tablets. */
320+ const val listContentMaxWidthDp = 600
321+
322+ /** Maximum width (dp) for the full-screen chat composer dialog on large screens, so
323+ * the editor doesn't stretch edge-to-edge on a tablet (matching [chatContentMaxWidthDp]'s
324+ * readability rationale for the message list). */
325+ const val composerDialogMaxWidthDp = 800
326+
327+ /** Maximum height (dp) for a modal bottom sheet's list area. Resolved against the screen
328+ * (not the partial sheet) so a sheet that starts fully expanded shows a predictable list
329+ * height regardless of the sheet's drag position. */
330+ const val pickerSheetMaxHeightDp = 560
331+
332+ /** Maximum inline height (dp) for an image attachment in the chat list. A tall image
333+ * caps at this so it doesn't dominate the bubble; tapping opens the fullscreen viewer. */
334+ const val inlineImageMaxHeightDp = 320
335+
336+ /** Minimum inline height (dp) reserved for an image placeholder (loading/error/resolving),
337+ * so the layout doesn't jump when the load completes and the image's intrinsic size is known. */
338+ const val inlineImageMinHeightDp = 120
339+
340+ /** Lines of a code fence rendered before collapsing to a head with a "show more" affordance.
341+ * Capped low for mobile: a 200-line block rendered at once can jank a low-end phone, so the
342+ * initial render is bounded and a "show more" reveals the rest in place. */
343+ const val collapsedCodeLineThreshold = 80
344+
345+ /** Lines of a unified diff rendered before collapsing to a head with a "show more" affordance.
346+ * Same mobile-performance rationale as [collapsedCodeLineThreshold]. */
347+ const val collapsedDiffLineThreshold = 80
348+
349+ /** Lines of tool output rendered before collapsing to a head with a "show more" affordance. */
350+ const val toolOutputCollapsedLimitChars = 4000
351+
352+ /** Lines of tool output rendered before collapsing to a head with a "show more" affordance.
353+ * A line-based threshold (rather than the char-based [toolOutputCollapsedLimitChars]) keeps
354+ * the collapsed preview a predictable height on mobile — a 4000-char block of short lines
355+ * can be 60+ lines and fill the whole screen, defeating the purpose of collapsing. The
356+ * char cap still applies as a hard upper bound; this line cap kicks in first for typical
357+ * multi-line output. */
358+ const val toolOutputCollapsedLimitLines = 30
359+
360+ /** Tool input (pretty-printed JSON) shorter than this many lines is shown expanded by
361+ * default — for tools like bash/write/edit the command is the most important info and
362+ * hiding it behind a tap inverts the priority. Longer inputs stay collapsed to avoid
363+ * dominating the bubble. */
364+ const val toolInputAutoExpandLineThreshold = 10
365+
366+ /** Tool durations below this many ms are hidden from the tool-name row (sub-second runs
367+ * are instant and the ms figure is noise). Set to 1000 to hide all sub-second durations,
368+ * or 0 to always show. */
369+ const val toolDurationHideBelowMs = 1_000L
370+
371+ /** Characters of tool output included in a shared/exported transcript before truncation. */
372+ const val exportToolOutputLimitChars = 2000
373+
374+ /** Maximum zoom factor applied by pinch or double-tap in the fullscreen image viewer. */
375+ const val imageViewerMaxZoom = 5f
376+
377+ /** Zoom factor a double-tap jumps to when starting from 1× in the fullscreen image viewer. */
378+ const val imageViewerDoubleTapZoom = 2.5f
379+
380+ /** Duration (ms) of the animated double-tap zoom transition in the fullscreen image viewer. */
381+ const val imageViewerZoomAnimMs = 220
382+
383+ /** Vertical drag distance (px) in the fullscreen image viewer that triggers swipe-to-dismiss. */
384+ const val imageViewerSwipeDismissThreshold = 200f
385+
386+ /** Blink period (ms) of the streaming caret at the tail of a live assistant reply. */
387+ const val streamingCaretPeriodMs = 500
388+
389+ /** Target maximum length (chars) of a single TTS chunk. Android's TextToSpeech enforces a
390+ * hard limit (getMaxSpeechInputLength, ~4000 chars) — this is a tighter cap so pause/
391+ * resume restarts at a finer granularity. Without it, a pause mid-chunk restarts the
392+ * whole chunk from its start; smaller chunks mean less replay on resume. Sentence
393+ * boundaries are still preferred, so short sentences pack up to this cap. */
394+ const val ttsChunkTargetMaxChars = 200
395+
396+ /** Auto-reject a tool-permission prompt after this many ms if the user hasn't responded,
397+ * so a forgotten prompt doesn't block the run indefinitely. The foreground service keeps
398+ * the process alive, so without a timeout a prompt that the user walked away from holds
399+ * the run open forever. The timeout is long (10 minutes) so a deliberate pause to read
400+ * the prompt doesn't trip it; a reminder toast-style line appears in the dialog after
401+ * [permissionReminderThresholdMs] to surface that the run is waiting. Set to 0 to disable. */
402+ const val permissionAutoRejectMs = 10L * 60 * 1000
403+
404+ /** Show a "still waiting" reminder in the permission dialog after this many ms. */
405+ const val permissionReminderThresholdMs = 60L * 1000
406+
407+ /** Duration (ms) the copy button shows its checkmark confirmation before reverting. */
408+ const val copyFeedbackMs = 1200
409+
410+ /** Gutter width (dp) for each side (old/new) of a unified-diff line-number column. */
411+ const val diffGutterWidthDp = 36
412+
413+ /** Width (dp) of the +/- prefix column in a unified-diff row. */
414+ const val diffPrefixWidthDp = 16
415+
416+ /** Maximum width (dp) of a breadcrumb segment in the file browser (non-last segments). */
417+ const val breadcrumbSegmentMaxWidthDp = 120
418+
419+ /** Maximum width (dp) of the last (current) breadcrumb segment in the file browser. */
420+ const val breadcrumbLastSegmentMaxWidthDp = 220
421+
422+ /** Vertical scroll threshold beyond which the chat "scroll to top" FAB appears. */
423+ const val chatScrollToTopFabThreshold = 200
424+
425+ /** Maximum width (dp) for an attachment chip's filename text before it truncates. */
426+ const val attachmentChipNameMaxWidthDp = 120
427+
428+ /** Maximum height (dp) for the expanded todo-plan checklist in a message bubble. */
429+ const val todoPlanMaxHeightDp = 240
430+
431+ /** Fallback height (dp) for the connection banner when no text is available (e.g. exit fade). */
432+ const val connectionBannerFallbackHeightDp = 44
433+
434+ // --- Motion tokens (shared across screens) ---
435+
436+ /** Duration (ms) for horizontal slide push/pop nav transitions. M3 spec recommends 200ms
437+ * for medium-duration container transitions. */
438+ const val motionSlideDurationMs = 200
439+ /** Duration (ms) for fade in/out nav and content transitions. */
440+ const val motionFadeDurationMs = 180
441+
442+ // --- Font scaling ---
443+
444+ /** Upper bound on the *combined* app×OS font scale. The chat text-size preference
445+ * multiplies on top of the system font scale, so a user at a large system size and
446+ * a large app size can reach ~2× and break tight layouts. The provided app scale is
447+ * clamped so the product with the OS fontScale never exceeds this. */
448+ const val maxCombinedFontScale = 1.8f
449+}
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.ktnew file+575
@@ -0,0 +1,575 @@
1+package soy.iko.opencode.data.network
2+
3+import soy.iko.opencode.data.model.Agent
4+import soy.iko.opencode.data.model.Command
5+import soy.iko.opencode.data.model.CommandRequest
6+import soy.iko.opencode.data.model.CreateSessionRequest
7+import soy.iko.opencode.data.model.FileContent
8+import soy.iko.opencode.data.model.FileNode
9+import soy.iko.opencode.data.model.FileStatusEntry
10+import soy.iko.opencode.data.model.MessageWithParts
11+import soy.iko.opencode.data.model.ModelRef
12+import soy.iko.opencode.data.model.PathInfo
13+import soy.iko.opencode.data.model.FilePromptPart
14+import soy.iko.opencode.data.model.FindMatch
15+import soy.iko.opencode.data.model.PermissionReplyBody
16+import soy.iko.opencode.data.model.PermissionResponse
17+import soy.iko.opencode.data.model.Project
18+import soy.iko.opencode.data.model.PromptPart
19+import soy.iko.opencode.data.model.PromptRequest
20+import soy.iko.opencode.data.model.ProvidersResponse
21+import soy.iko.opencode.data.model.RevertRequest
22+import soy.iko.opencode.data.model.Session
23+import soy.iko.opencode.data.model.ShellRequest
24+import soy.iko.opencode.data.model.SummarizeRequest
25+import soy.iko.opencode.data.model.SymbolResult
26+import soy.iko.opencode.data.model.TextPromptPart
27+import soy.iko.opencode.data.model.UpdateSessionRequest
28+import io.ktor.client.HttpClient
29+import io.ktor.client.call.body
30+import io.ktor.client.plugins.ClientRequestException
31+import io.ktor.client.plugins.ResponseException
32+import io.ktor.client.plugins.ServerResponseException
33+import io.ktor.client.request.delete
34+import io.ktor.client.request.get
35+import io.ktor.client.request.header
36+import io.ktor.client.request.parameter
37+import io.ktor.client.request.patch
38+import io.ktor.client.request.post
39+import io.ktor.client.request.setBody
40+import io.ktor.http.ContentType
41+import io.ktor.http.contentType
42+import kotlinx.coroutines.delay
43+import kotlinx.coroutines.sync.Mutex
44+import kotlinx.coroutines.sync.withLock
45+import kotlinx.serialization.json.buildJsonObject
46+import kotlinx.serialization.json.put
47+import kotlin.random.Random
48+import java.net.URLEncoder
49+
50+/**
51+ * Request/response wrapper over the opencode REST endpoints. The long-lived `/event`
52+ * SSE stream lives in [EventStreamClient]; everything else is here.
53+ */
54+open class OpencodeApiClient private constructor(
55+ private val client: HttpClient?,
56+ @Suppress("unused") private val testMode: Boolean,
57+) {
58+ constructor(client: HttpClient) : this(client, false)
59+ protected constructor() : this(null, true)
60+
61+ // --- Catalog cache (providers/agents/commands) ---
62+ // These change rarely, but the ChatViewModel re-fetches them on every creation.
63+ // A short-lived in-memory cache avoids redundant network calls when navigating
64+ // between sessions on the same server.
65+ private data class CachedEntry<T>(val value: T, val fetchedAt: Long)
66+ // Per-catalog mutexes so a slow fetch of one catalog doesn't block the others.
67+ // The fetch runs OUTSIDE the lock (holding it across the network call would serialize
68+ // all callers and stall invalidation). To keep that correct, each catalog carries a
69+ // generation counter, bumped under the mutex by its invalidateXCache(): a fetch captures
70+ // the generation before it starts and, when it completes, only writes its result back if
71+ // the generation is unchanged — so an invalidation that raced the in-flight fetch is not
72+ // defeated by the pre-invalidation result being stored afterwards.
73+ private val providersMutex = Mutex()
74+ private val agentsMutex = Mutex()
75+ private val commandsMutex = Mutex()
76+ private var cachedProviders: CachedEntry<ProvidersResponse>? = null
77+ private var cachedAgents: CachedEntry<List<Agent>>? = null
78+ private var cachedCommands: CachedEntry<List<Command>>? = null
79+ // Generation counters (guarded by the matching mutex above) for the invalidation-race guard.
80+ private var providersGeneration = 0
81+ private var agentsGeneration = 0
82+ private var commandsGeneration = 0
83+
84+ /** Lightweight connectivity check. Throws on non-2xx / network failure. */
85+ open suspend fun ping() {
86+ withRetry { client!!.get("global/health").body<String>() }
87+ }
88+
89+ open suspend fun listSessions(): List<Session> = withRetry {
90+ client!!.get("session").body()
91+ }
92+
93+ /**
94+ * Create a session, optionally in a specific worktree [directory]. The directory is a
95+ * query parameter (not a body field): the server records it on the session and resolves
96+ * it by session id thereafter, so follow-up calls (messages, commands, abort, …) don't
97+ * — and shouldn't — re-send it. A null/blank [directory] lets the server fall back to
98+ * its own launch cwd, preserving the pre-directory behavior.
99+ */
100+ open suspend fun createSession(title: String? = null, directory: String? = null): Session {
101+ // Generate the idempotency key before entering withRetry so every retry attempt
102+ // shares it. Without this, a POST that reached the server but whose response was
103+ // lost (reset/timeout) would be retried and create a second orphan session.
104+ val idempotencyKey = java.util.UUID.randomUUID().toString()
105+ val dir = directory?.takeIf { it.isNotBlank() }
106+ return withRetry {
107+ client!!.post("session") {
108+ contentType(ContentType.Application.Json)
109+ header("Idempotency-Key", idempotencyKey)
110+ if (dir != null) parameter("directory", dir)
111+ setBody(CreateSessionRequest(title = title))
112+ }.body()
113+ }
114+ }
115+
116+ /** List the projects (worktree roots) the server knows about, for the directory picker. */
117+ open suspend fun listProjects(): List<Project> = withRetry {
118+ client!!.get("project").body()
119+ }
120+
121+ /** The server's current path info; its [PathInfo.directory] is the default (cwd) a
122+ * session with no directory override runs in. */
123+ open suspend fun currentPath(): PathInfo = withRetry {
124+ client!!.get("path").body()
125+ }
126+
127+ open suspend fun updateSession(id: String, title: String): Session = withRetry {
128+ client!!.patch("session/${encode(id)}") {
129+ contentType(ContentType.Application.Json)
130+ setBody(UpdateSessionRequest(title = title))
131+ }.body()
132+ }
133+
134+ open suspend fun deleteSession(id: String) {
135+ try {
136+ withRetry { client!!.delete("session/${encode(id)}").body<String>() }
137+ } catch (t: ClientRequestException) {
138+ // A DELETE that succeeded but whose response was lost gets retried; the retry
139+ // hits an already-gone session and returns 404. Deleting a non-existent session
140+ // is effectively success, so swallow 404 rather than surfacing a spurious failure.
141+ if (t.response.status.value != 404) throw t
142+ }
143+ }
144+
145+ open suspend fun listMessages(sessionId: String): List<MessageWithParts> = withRetry {
146+ client!!.get("session/${encode(sessionId)}/message").body()
147+ }
148+
149+ open suspend fun sendPrompt(
150+ sessionId: String,
151+ text: String,
152+ attachments: List<FilePromptPart> = emptyList(),
153+ model: ModelRef? = null,
154+ agent: String? = null,
155+ // A caller-stable idempotency key. Callers that may re-invoke sendPrompt across
156+ // *separate* calls for the same logical message (the offline outbox, which re-sends
157+ // a message that stayed queued after an earlier flush failed) must pass a stable key
158+ // derived from the persistent message id — otherwise each attempt mints a fresh key
159+ // and a POST that reached the server but whose response was lost is re-created as a
160+ // duplicate run. When null, a fresh key is generated (correct for one-shot sends).
161+ idempotencyKey: String? = null,
162+ ): MessageWithParts {
163+ // Resolve the key *before* entering withRetry so all retry attempts share it. If the
164+ // key were generated inside withRetry, each attempt would get a fresh UUID and the
165+ // server couldn't deduplicate a request that reached it but whose response was lost.
166+ val resolvedKey = idempotencyKey ?: java.util.UUID.randomUUID().toString()
167+ // A text part is only included when there's actual text, so an image-only prompt
168+ // (attachments, empty text) doesn't send an empty text part. Attachments follow
169+ // the text so the model reads the instruction first.
170+ val parts = buildList<PromptPart> {
171+ if (text.isNotEmpty()) add(TextPromptPart(text))
172+ addAll(attachments)
173+ }
174+ return withRetry {
175+ client!!.post("session/${encode(sessionId)}/message") {
176+ contentType(ContentType.Application.Json)
177+ header("Idempotency-Key", resolvedKey)
178+ setBody(
179+ PromptRequest(
180+ parts = parts,
181+ model = model,
182+ agent = agent,
183+ ),
184+ )
185+ }.body()
186+ }
187+ }
188+
189+ /** Abort the active run in a session. NOT retried: a lost response followed by a
190+ * retry would POST abort again, and if a new run started in the gap the retry
191+ * would kill that *new* run. Unlike sendPrompt/runCommand/summarize there's no
192+ * Idempotency-Key the server could use to dedupe, so a single attempt is the
193+ * safe choice — the SSE stream reflects the actual run state regardless. */
194+ open suspend fun abort(sessionId: String) {
195+ withRetry(maxAttempts = 1) { client!!.post("session/${encode(sessionId)}/abort").body<String>() }
196+ }
197+
198+ /** Invoke a slash-command by name via `POST /session/:id/command`. */
199+ open suspend fun runCommand(
200+ sessionId: String,
201+ command: String,
202+ arguments: String = "",
203+ agent: String? = null,
204+ ): MessageWithParts {
205+ // Generate the idempotency key before entering withRetry so all retry
206+ // attempts share the same key, preventing duplicate command execution if
207+ // the server processed the request but the response was lost.
208+ val idempotencyKey = java.util.UUID.randomUUID().toString()
209+ return withRetry {
210+ client!!.post("session/${encode(sessionId)}/command") {
211+ contentType(ContentType.Application.Json)
212+ header("Idempotency-Key", idempotencyKey)
213+ setBody(CommandRequest(command = command, arguments = arguments, agent = agent))
214+ }.body()
215+ }
216+ }
217+
218+ /** Revert the session to just before [messageId] (optionally part [partId]); the
219+ * server hides everything after the checkpoint. Returns the updated session.
220+ *
221+ * NOT retried (single attempt), mirroring [abort]: a lost response followed by a retry
222+ * would POST revert again, and if a new run started in the gap the retry would hide
223+ * that new run's messages too (until the user noticed and called [unrevert]). There's
224+ * no Idempotency-Key the server could dedupe on, so a single attempt is the safe
225+ * choice — the SSE stream reflects the actual revert state regardless. */
226+ open suspend fun revert(sessionId: String, messageId: String, partId: String? = null): Session = withRetry(maxAttempts = 1) {
227+ client!!.post("session/${encode(sessionId)}/revert") {
228+ contentType(ContentType.Application.Json)
229+ setBody(RevertRequest(messageID = messageId, partID = partId))
230+ }.body()
231+ }
232+
233+ /** Undo the active revert checkpoint, restoring the hidden messages.
234+ *
235+ * Mint an Idempotency-Key *before* withRetry so a retried POST (first request reached
236+ * the server but its response was lost) doesn't trigger a second unrevert on a session
237+ * whose checkpoint was already cleared — the second attempt would hit a session with no
238+ * active revert and the server could return a non-retryable 4xx, surfacing a spurious
239+ * error for an operation that actually succeeded. The key lets the server dedupe. */
240+ open suspend fun unrevert(sessionId: String): Session {
241+ val idempotencyKey = java.util.UUID.randomUUID().toString()
242+ return withRetry {
243+ client!!.post("session/${encode(sessionId)}/unrevert") {
244+ header("Idempotency-Key", idempotencyKey)
245+ }.body()
246+ }
247+ }
248+
249+ /** Create a public share link for the session. Returns the session with [Session.share] set.
250+ * Mint the Idempotency-Key *before* withRetry so a retried POST (first request reached the
251+ * server but its response was lost) doesn't create a second, orphaned, still-valid share. */
252+ open suspend fun shareSession(sessionId: String): Session {
253+ val idempotencyKey = java.util.UUID.randomUUID().toString()
254+ return withRetry {
255+ client!!.post("session/${encode(sessionId)}/share") {
256+ header("Idempotency-Key", idempotencyKey)
257+ }.body()
258+ }
259+ }
260+
261+ /** Revoke the session's public share link. Returns the session with share cleared.
262+ * Like [deleteSession], a retried DELETE (first reached the server but the response was
263+ * lost) hits an already-unshared session and gets 404 — revoking a non-shared session is
264+ * effectively success, so swallow 404 rather than surfacing a spurious failure.
265+ * The fallback `GET` re-reads the current session state so the caller sees the cleared
266+ * share. If the session itself was deleted in the window between the DELETE-404 and the
267+ * GET (race with another client / a concurrent deleteSession), the GET also 404s —
268+ * swallow that too and surface a synthesized gone-session rather than an unexpected
269+ * ClientRequestException, mirroring deleteSession's contract. */
270+ open suspend fun unshareSession(sessionId: String): Session {
271+ return try {
272+ withRetry { client!!.delete("session/${encode(sessionId)}/share").body() }
273+ } catch (t: ClientRequestException) {
274+ if (t.response.status.value != 404) throw t
275+ try {
276+ withRetry { client!!.get("session/${encode(sessionId)}").body<Session>() }
277+ } catch (t2: ClientRequestException) {
278+ if (t2.response.status.value != 404) throw t2
279+ Session(id = sessionId, title = null, share = null)
280+ }
281+ }
282+ }
283+
284+ /** Ask the agent to summarize/compact the conversation to reclaim context. The compacted
285+ * summary streams back via SSE like any run; this just triggers it. */
286+ open suspend fun summarize(sessionId: String, model: ModelRef) {
287+ val idempotencyKey = java.util.UUID.randomUUID().toString()
288+ withRetry {
289+ client!!.post("session/${encode(sessionId)}/summarize") {
290+ contentType(ContentType.Application.Json)
291+ header("Idempotency-Key", idempotencyKey)
292+ setBody(SummarizeRequest(providerID = model.providerID, modelID = model.modelID))
293+ }.body<String>()
294+ }
295+ }
296+
297+ /** Analyze the project and (re)generate its AGENTS.md. The body is omitted so the server
298+ * assigns the message id and uses its default model; the run streams back via SSE. */
299+ open suspend fun initSession(sessionId: String) {
300+ val idempotencyKey = java.util.UUID.randomUUID().toString()
301+ withRetry {
302+ client!!.post("session/${encode(sessionId)}/init") {
303+ header("Idempotency-Key", idempotencyKey)
304+ }.body<String>()
305+ }
306+ }
307+
308+ /** Run a one-off shell [command] in the session's worktree. Output streams back via SSE.
309+ * [agent] scopes the run (the server requires it). */
310+ open suspend fun shell(sessionId: String, command: String, agent: String, model: ModelRef? = null) {
311+ val idempotencyKey = java.util.UUID.randomUUID().toString()
312+ withRetry {
313+ client!!.post("session/${encode(sessionId)}/shell") {
314+ contentType(ContentType.Application.Json)
315+ header("Idempotency-Key", idempotencyKey)
316+ setBody(ShellRequest(agent = agent, command = command, model = model))
317+ }.body<String>()
318+ }
319+ }
320+
321+ open suspend fun providers(): ProvidersResponse {
322+ val generation: Int
323+ providersMutex.withLock {
324+ val cached = cachedProviders
325+ if (cached != null && System.currentTimeMillis() - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
326+ return cached.value
327+ }
328+ generation = providersGeneration
329+ }
330+ // Fetch outside the lock so a slow/retrying request (and its backoff) can't stall other
331+ // callers or cache invalidation. Concurrent cold callers may double-fetch — acceptable
332+ // for a rarely-fetched catalog — and the last store wins.
333+ val value = withRetry { client!!.get("config/providers").body<ProvidersResponse>() }
334+ providersMutex.withLock {
335+ // FIX 15: skip the store if an invalidateProvidersCache() raced this fetch — the
336+ // result predates the invalidation and would otherwise resurrect stale data.
337+ if (providersGeneration == generation) cachedProviders = CachedEntry(value, System.currentTimeMillis())
338+ }
339+ return value
340+ }
341+
342+ /** The server's resolved configuration as a raw JSON object. Parsed loosely by callers
343+ * (e.g. the MCP viewer) so a schema addition on the server never breaks decoding. */
344+ open suspend fun config(): kotlinx.serialization.json.JsonObject = withRetry {
345+ client!!.get("config").body()
346+ }
347+
348+ /** Live MCP server status (`GET /mcp`): a map of server name to its status (connected,
349+ * available tools, any error). Decoded as a raw JSON object so a schema change on the
350+ * server never breaks the viewer. */
351+ open suspend fun mcpStatus(): kotlinx.serialization.json.JsonObject = withRetry {
352+ client!!.get("mcp").body()
353+ }
354+
355+ /** Dynamically register an MCP server (`POST /mcp`). [config] is the server's config object
356+ * (type/command-or-url/env…), sent as `{ "name": [name], "config": [config] }`. Mint the
357+ * Idempotency-Key *before* withRetry so a retried POST (first reached the server but the
358+ * response was lost) doesn't register a duplicate. */
359+ open suspend fun addMcp(name: String, config: kotlinx.serialization.json.JsonObject): kotlinx.serialization.json.JsonObject {
360+ val idempotencyKey = java.util.UUID.randomUUID().toString()
361+ return withRetry {
362+ client!!.post("mcp") {
363+ contentType(ContentType.Application.Json)
364+ header("Idempotency-Key", idempotencyKey)
365+ setBody(
366+ buildJsonObject {
367+ put("name", name)
368+ put("config", config)
369+ },
370+ )
371+ }.body()
372+ }
373+ }
374+
375+ open suspend fun agents(): List<Agent> {
376+ val generation: Int
377+ agentsMutex.withLock {
378+ val cached = cachedAgents
379+ if (cached != null && System.currentTimeMillis() - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
380+ return cached.value
381+ }
382+ generation = agentsGeneration
383+ }
384+ // Fetch outside the lock (see providers()): a slow/retrying request can't stall other
385+ // callers or cache invalidation; a rare concurrent double-fetch is acceptable here.
386+ val value = withRetry { client!!.get("agent").body<List<Agent>>() }
387+ agentsMutex.withLock {
388+ // FIX 15: skip the store if an invalidateAgentsCache() raced this fetch (see providers()).
389+ if (agentsGeneration == generation) cachedAgents = CachedEntry(value, System.currentTimeMillis())
390+ }
391+ return value
392+ }
393+
394+ open suspend fun commands(): List<Command> {
395+ val generation: Int
396+ commandsMutex.withLock {
397+ val cached = cachedCommands
398+ if (cached != null && System.currentTimeMillis() - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
399+ return cached.value
400+ }
401+ generation = commandsGeneration
402+ }
403+ // Fetch outside the lock (see providers()): a slow/retrying request can't stall other
404+ // callers or cache invalidation; a rare concurrent double-fetch is acceptable here.
405+ val value = withRetry { client!!.get("command").body<List<Command>>() }
406+ commandsMutex.withLock {
407+ // FIX 15: skip the store if an invalidateCommandsCache() raced this fetch (see providers()).
408+ if (commandsGeneration == generation) cachedCommands = CachedEntry(value, System.currentTimeMillis())
409+ }
410+ return value
411+ }
412+
413+ /** Invalidate the catalog cache (e.g. on server switch). */
414+ open suspend fun invalidateCache() {
415+ // FIX 15: bump each generation so an in-flight fetch drops its now-stale result.
416+ providersMutex.withLock { cachedProviders = null; providersGeneration++ }
417+ agentsMutex.withLock { cachedAgents = null; agentsGeneration++ }
418+ commandsMutex.withLock { cachedCommands = null; commandsGeneration++ }
419+ }
420+
421+ open suspend fun invalidateProvidersCache() {
422+ providersMutex.withLock { cachedProviders = null; providersGeneration++ }
423+ }
424+
425+ open suspend fun invalidateAgentsCache() {
426+ agentsMutex.withLock { cachedAgents = null; agentsGeneration++ }
427+ }
428+
429+ open suspend fun invalidateCommandsCache() {
430+ commandsMutex.withLock { cachedCommands = null; commandsGeneration++ }
431+ }
432+
433+ /** Respond to a permission request so a paused tool run can proceed. */
434+ open suspend fun respondPermission(
435+ sessionId: String,
436+ permissionId: String,
437+ response: PermissionResponse,
438+ ): String = try {
439+ withRetry {
440+ client!!.post("session/${encode(sessionId)}/permissions/${encode(permissionId)}") {
441+ contentType(ContentType.Application.Json)
442+ setBody(PermissionReplyBody(response.wire))
443+ }.body<String>()
444+ }
445+ } catch (t: ClientRequestException) {
446+ // A response that reached the server but whose reply was lost gets retried; the
447+ // retry finds the permission already resolved (404 gone / 409 conflict). Both mean
448+ // the answer landed (or the session/permission no longer exists, in which case the
449+ // response is moot anyway), so treat them as success instead of a spurious failure
450+ // that would re-queue the dialog against a gone session. Log the swallowed status
451+ // so a 404 caused by a deleted session is debuggable rather than fully silent.
452+ val code = t.response.status.value
453+ if (code != 404 && code != 409) throw t
454+ android.util.Log.d("OpencodeApi", "respondPermission swallowed $code for session=$sessionId permission=$permissionId")
455+ ""
456+ }
457+
458+ // --- Files ---
459+
460+ open suspend fun findFiles(query: String): List<String> = withRetry {
461+ client!!.get("find/file") { parameter("query", query) }.body()
462+ }
463+
464+ /** Ripgrep content search across the project (`GET /find?pattern=`). */
465+ open suspend fun findText(pattern: String): List<FindMatch> = withRetry {
466+ client!!.get("find") { parameter("pattern", pattern) }.body()
467+ }
468+
469+ /** LSP workspace symbol search (`GET /find/symbol?query=`). */
470+ open suspend fun findSymbol(query: String): List<SymbolResult> = withRetry {
471+ client!!.get("find/symbol") { parameter("query", query) }.body()
472+ }
473+
474+ open suspend fun listDirectory(path: String): List<FileNode> = withRetry {
475+ client!!.get("file") { parameter("path", path) }.body()
476+ }
477+
478+ open suspend fun readFile(path: String): FileContent = withRetry {
479+ client!!.get("file/content") { parameter("path", path) }.body()
480+ }
481+
482+ open suspend fun fileStatus(): List<FileStatusEntry> = withRetry {
483+ client!!.get("file/status").body()
484+ }
485+}
486+
487+private suspend fun <T> withRetry(
488+ maxAttempts: Int = NetworkConfig.retryMaxAttempts,
489+ initialDelayMs: Long = NetworkConfig.retryInitialDelayMs,
490+ block: suspend () -> T,
491+): T = withRetryInternal(maxAttempts, initialDelayMs, jitterFactor = NetworkConfig.retryJitterFactor, block)
492+
493+/**
494+ * Retry a suspending block up to [maxAttempts] times with exponential backoff.
495+ * Cancellation exceptions are re-thrown immediately. Client errors (4xx) are
496+ * non-retryable — a 401 or 404 won't succeed on retry, so they surface immediately
497+ * instead of wasting up to 3.5 s of backoff. Network failures, timeouts, and 5xx
498+ * server errors are retried. Exposed as `internal` so the retry/skip rules are
499+ * unit-testable without an HTTP server.
500+ */
501+internal suspend fun <T> withRetryInternal(
502+ maxAttempts: Int = NetworkConfig.retryMaxAttempts,
503+ initialDelayMs: Long = NetworkConfig.retryInitialDelayMs,
504+ jitterFactor: Double = NetworkConfig.retryJitterFactor,
505+ block: suspend () -> T,
506+): T {
507+ var lastError: Throwable? = null
508+ for (attempt in 1..maxAttempts) {
509+ try {
510+ return block()
511+ } catch (c: kotlinx.coroutines.CancellationException) {
512+ throw c
513+ } catch (t: ClientRequestException) {
514+ // 408 (Request Timeout) and 429 (Too Many Requests) are transient — retry with
515+ // backoff. All other 4xx are non-retryable (a 401/404 won't succeed on retry).
516+ val status = t.response.status.value
517+ if (status != 408 && status != 429) throw t
518+ lastError = t
519+ if (attempt < maxAttempts) {
520+ delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
521+ }
522+ } catch (t: ServerResponseException) {
523+ // 5xx server errors are retryable with backoff.
524+ lastError = t
525+ if (attempt < maxAttempts) {
526+ delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
527+ }
528+ } catch (t: ResponseException) {
529+ // 3xx (redirect limit exceeded) is non-retryable.
530+ throw t
531+ } catch (t: io.ktor.serialization.ContentConvertException) {
532+ // A malformed/unexpected response body (deserialization failure via content
533+ // negotiation) is deterministic — it fails identically on every attempt — so
534+ // retrying only burns the whole backoff budget re-parsing the same bytes. Fail
535+ // fast instead.
536+ throw t
537+ } catch (t: IllegalArgumentException) {
538+ // Also deterministic: a bad argument, or a raw kotlinx SerializationException
539+ // (which extends IllegalArgumentException) thrown outside content negotiation.
540+ throw t
541+ } catch (t: Exception) {
542+ lastError = t
543+ if (attempt < maxAttempts) {
544+ delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
545+ }
546+ }
547+ }
548+ throw lastError ?: IllegalStateException("withRetry failed without error")
549+}
550+
551+/** Computes an exponential backoff delay with symmetric jitter. Exposed as `internal`
552+ * so the backoff formula is unit-testable without an HTTP server. */
553+internal fun jitteredBackoff(
554+ attempt: Int,
555+ initialDelayMs: Long,
556+ jitterFactor: Double,
557+): Long {
558+ // Guard against Int overflow from the shift and Long overflow from
559+ // the multiplication at high attempt counts.
560+ val shift = (attempt - 1).coerceAtMost(30)
561+ val baseDelay = (initialDelayMs * (1L shl shift)).coerceAtMost(NetworkConfig.retryMaxDelayMs)
562+ // Symmetric jitter: vary by ±jitterFactor so the delay is sometimes
563+ // shorter, sometimes longer — spreads concurrent client retries and
564+ // prevents thundering-herd reconnection storms.
565+ val jitter = ((baseDelay * jitterFactor) * (Random.nextDouble() * 2 - 1)).toLong()
566+ return (baseDelay + jitter).coerceAtLeast(0)
567+}
568+
569+/** URL-encode a path segment, replacing + with %20 (URLEncoder uses query-param encoding).
570+ * URLEncoder leaves '.' unencoded, so a "." or ".." segment would otherwise survive as a real
571+ * path-traversal component; percent-encode a segment that is nothing but dots. */
572+private fun encode(segment: String): String {
573+ val encoded = URLEncoder.encode(segment, "UTF-8").replace("+", "%20")
574+ return if (encoded.isNotEmpty() && encoded.all { it == '.' }) encoded.replace(".", "%2E") else encoded
575+}
app/src/main/java/soy/iko/opencode/data/network/OpencodeJson.ktnew file+37
@@ -0,0 +1,37 @@
1+package soy.iko.opencode.data.network
2+
3+import soy.iko.opencode.data.model.BusEvent
4+import soy.iko.opencode.data.model.MessageInfo
5+import soy.iko.opencode.data.model.Part
6+import soy.iko.opencode.data.model.ToolState
7+import soy.iko.opencode.data.model.UnknownEvent
8+import soy.iko.opencode.data.model.UnknownMessage
9+import soy.iko.opencode.data.model.UnknownPart
10+import soy.iko.opencode.data.model.ToolUnknown
11+import kotlinx.serialization.ExperimentalSerializationApi
12+import kotlinx.serialization.json.Json
13+import kotlinx.serialization.modules.SerializersModule
14+
15+/**
16+ * Single shared Json for the whole app.
17+ *
18+ * The polymorphic default deserializers are the resilience layer: when the server
19+ * sends a `type` / `role` / `status` we don't model, decoding falls back to the
20+ * Unknown variant instead of throwing — so a new opencode release can't break the
21+ * event stream or message decoding.
22+ */
23+@OptIn(ExperimentalSerializationApi::class)
24+val OpencodeJson: Json = Json {
25+ ignoreUnknownKeys = true
26+ isLenient = true
27+ coerceInputValues = true
28+ encodeDefaults = true
29+ explicitNulls = false
30+ classDiscriminator = "type"
31+ serializersModule = SerializersModule {
32+ polymorphicDefaultDeserializer(Part::class) { UnknownPart.serializer() }
33+ polymorphicDefaultDeserializer(BusEvent::class) { UnknownEvent.serializer() }
34+ polymorphicDefaultDeserializer(MessageInfo::class) { UnknownMessage.serializer() }
35+ polymorphicDefaultDeserializer(ToolState::class) { ToolUnknown.serializer() }
36+ }
37+}
app/src/main/java/soy/iko/opencode/data/repo/AttachmentDraftStore.ktnew file+117
@@ -0,0 +1,117 @@
1+package soy.iko.opencode.data.repo
2+
3+import android.content.Context
4+import android.util.Log
5+import kotlinx.coroutines.Dispatchers
6+import kotlinx.coroutines.sync.Mutex
7+import kotlinx.coroutines.sync.withLock
8+import kotlinx.coroutines.withContext
9+import kotlinx.serialization.Serializable
10+import kotlinx.serialization.decodeFromString
11+import kotlinx.serialization.encodeToString
12+import kotlinx.serialization.json.Json
13+import soy.iko.opencode.util.runCatchingCancellable
14+import java.io.File
15+
16+/**
17+ * One staged (not-yet-sent) attachment, in the minimal form needed to re-send and preview
18+ * it after a process restart. Only the self-contained wire payload is kept — the base64
19+ * `data:` [url] — because the original content Uri's read permission does NOT survive a
20+ * process restart, whereas the data URL is self-sufficient (and Coil re-renders it as the
21+ * thumbnail for images).
22+ */
23+@Serializable
24+data class PersistedAttachment(
25+ val id: String,
26+ val name: String,
27+ val mime: String,
28+ val url: String,
29+ val filename: String? = null,
30+)
31+
32+/**
33+ * Persists attachments staged for each session so an interrupted compose survives process
34+ * death, mirroring how [DraftStore] persists the text draft.
35+ *
36+ * Backed by one small JSON file per session under filesDir (not cacheDir, which the OS may
37+ * evict) rather than SharedPreferences: a base64 payload can be several megabytes and
38+ * SharedPreferences loads its whole file into memory on first access.
39+ */
40+open class AttachmentDraftStore private constructor(
41+ private val appContext: Context?,
42+ @Suppress("unused") private val testMode: Boolean,
43+) {
44+ constructor(context: Context) : this(context.applicationContext, false)
45+ protected constructor() : this(null, true)
46+
47+ private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
48+
49+ /** Session ids are validated tokens upstream, but sanitize anyway so a stray id can't
50+ * escape the directory. */
51+ private val idRegex = Regex("[^A-Za-z0-9_-]")
52+
53+ private val dir: File? by lazy {
54+ appContext?.let { File(it.filesDir, "attachment_drafts").apply { mkdirs() } }
55+ }
56+
57+ private fun fileFor(sessionId: String): File? =
58+ dir?.let { File(it, idRegex.replace(sessionId, "_") + ".json") }
59+
60+ /** Per-session write lock so rapid add/remove/clear mutations (each launched as an
61+ * independent coroutine by the ViewModel) serialize their writes to the same file
62+ * instead of racing — otherwise an older snapshot could land last and overwrite the
63+ * newer one on disk, or two writes could tear the file. */
64+ private val locks = java.util.concurrent.ConcurrentHashMap<String, Mutex>()
65+ private fun lockFor(sessionId: String): Mutex = locks.getOrPut(sessionId) { Mutex() }
66+
67+ /** Load the staged attachments for [sessionId], or empty if none / unreadable. */
68+ open suspend fun load(sessionId: String): List<PersistedAttachment> = lockFor(sessionId).withLock {
69+ withContext(Dispatchers.IO) {
70+ val file = fileFor(sessionId)?.takeIf { it.exists() } ?: return@withContext emptyList()
71+ runCatchingCancellable { json.decodeFromString<List<PersistedAttachment>>(file.readText()) }
72+ .getOrDefault(emptyList())
73+ }
74+ }
75+
76+ /** Persist [attachments] for [sessionId] (an empty list deletes the file). */
77+ open suspend fun save(sessionId: String, attachments: List<PersistedAttachment>): Unit = lockFor(sessionId).withLock {
78+ withContext(Dispatchers.IO) {
79+ val file = fileFor(sessionId) ?: return@withContext
80+ runCatchingCancellable {
81+ if (attachments.isEmpty()) file.delete()
82+ else {
83+ // FIX: write atomically (temp file + rename) like RecentSessionsStore so a
84+ // process kill mid-write can't leave a truncated file that fails to decode
85+ // and silently drops the staged attachments on reload.
86+ val encoded = json.encodeToString(attachments)
87+ val tmp = File(file.parentFile, file.name + ".tmp")
88+ tmp.writeText(encoded)
89+ if (!tmp.renameTo(file)) {
90+ try {
91+ file.writeText(encoded)
92+ } finally {
93+ tmp.delete()
94+ }
95+ }
96+ }
97+ }.onFailure { Log.w("AttachmentDraftStore", "Failed to persist attachments for $sessionId", it) }
98+ Unit
99+ }
100+ }
101+
102+ /** Remove staged attachments for a session (call on session deletion). */
103+ open suspend fun remove(sessionId: String) {
104+ // Keep the per-session Mutex in [locks] rather than evicting it: remove() is not always
105+ // terminal — a concurrent teardown save() for the same session must serialize against this
106+ // delete under the *same* Mutex instance. Evicting here would let that save() acquire a
107+ // fresh Mutex, so the two operations no longer serialize (resurrecting an orphan file, or
108+ // theoretically tearing a write). The bounded per-session map growth matches the other
109+ // lock-keyed stores.
110+ lockFor(sessionId).withLock {
111+ withContext(Dispatchers.IO) {
112+ runCatchingCancellable { fileFor(sessionId)?.delete() }
113+ Unit
114+ }
115+ }
116+ }
117+}
app/src/main/java/soy/iko/opencode/data/repo/BackupManager.ktnew file+214
@@ -0,0 +1,214 @@
1+package soy.iko.opencode.data.repo
2+
3+import kotlinx.coroutines.flow.first
4+import kotlinx.serialization.Serializable
5+import kotlinx.serialization.decodeFromString
6+import kotlinx.serialization.encodeToString
7+import kotlinx.serialization.json.Json
8+import soy.iko.opencode.data.model.ServerProfile
9+
10+/** A server entry in a backup. [password] is present only when the user opted to include it. */
11+@Serializable
12+data class BackupServer(
13+ val id: String,
14+ val label: String,
15+ val baseUrl: String,
16+ val username: String? = null,
17+ val password: String? = null,
18+ val requireHttps: Boolean = false,
19+ val certPin: String? = null,
20+)
21+
22+/** App preferences in a backup. */
23+@Serializable
24+data class BackupSettings(
25+ // Every field is defaulted (to SettingsStore's own defaults) so a backup written by an older
26+ // app version — one whose settings block predates a field added later — still decodes. Without
27+ // defaults a missing field throws MissingFieldException in import() BEFORE the server-restore
28+ // loop, aborting the ENTIRE restore (servers/pins/archives included) despite the best-effort
29+ // contract. ignoreUnknownKeys only tolerates EXTRA keys, not missing required ones.
30+ val themeMode: String = ThemeMode.SYSTEM.name,
31+ val dynamicColor: Boolean = false,
32+ val sendOnEnter: Boolean = true,
33+ val appLock: Boolean = false,
34+ val appLockReLockSeconds: Int = SettingsStore.DEFAULT_APP_LOCK_RELOCK_SECONDS,
35+ val chatTextScale: Float = SettingsStore.DEFAULT_CHAT_TEXT_SCALE,
36+ val codeWrap: Boolean = false,
37+ val sessionSortMode: String = "RECENT",
38+ val sessionSortDescending: Boolean = true,
39+ val sessionShowArchived: Boolean = false,
40+ val preferredModelId: String = "",
41+ val preferredAgentName: String = "",
42+ val compactMessageSpacing: Boolean = false,
43+ val hapticsEnabled: Boolean = true,
44+ val reducedMotion: Boolean = false,
45+ val languageOverride: String = "",
46+ val notifRunComplete: Boolean = true,
47+ val notifPermission: Boolean = true,
48+ val notifError: Boolean = true,
49+ val swipeLeftAction: String = SwipeAction.DELETE.name,
50+ val swipeRightAction: String = SwipeAction.ARCHIVE.name,
51+)
52+
53+/** The full backup document. [version] guards against future format changes: [import] rejects
54+ * an unknown major version before any writes so a newer-schema backup can't silently
55+ * mis-import or leave a partial restore. */
56+@Serializable
57+data class BackupData(
58+ val version: Int = 1,
59+ val servers: List<BackupServer> = emptyList(),
60+ val settings: BackupSettings? = null,
61+ val pinned: List<String> = emptyList(),
62+ val archived: List<String> = emptyList(),
63+)
64+
65+/**
66+ * Exports/imports the app's servers, settings, and per-session flags as a single JSON
67+ * document (via the Storage Access Framework in the UI). Passwords are only written when the
68+ * user explicitly opts in, since the exported file is plaintext and leaves the app's encrypted
69+ * store.
70+ */
71+class BackupManager(
72+ private val profileStore: ProfileStore,
73+ private val settingsStore: SettingsStore,
74+ private val sessionPrefsStore: SessionPrefsStore,
75+) {
76+ private val json = Json { prettyPrint = true; ignoreUnknownKeys = true; encodeDefaults = true }
77+
78+ /** Backup format versions [import] knows how to restore. Bump when the schema changes
79+ * incompatibly, and add the new value here once the import path handles it. An unknown
80+ * version is rejected before any writes so a newer-schema backup can't silently
81+ * mis-import or leave a partial restore (servers upserted, then a missing required field
82+ * throws mid-settings). */
83+ private val supportedVersions = setOf(1)
84+
85+ /** Build the backup JSON. Reads the current profiles/settings/flags. */
86+ suspend fun export(includePasswords: Boolean): String {
87+ val servers = profileStore.profiles.first().map {
88+ BackupServer(
89+ id = it.id,
90+ label = it.label,
91+ baseUrl = it.baseUrl,
92+ username = it.username,
93+ password = if (includePasswords) it.password else null,
94+ requireHttps = it.requireHttps,
95+ certPin = it.certPin,
96+ )
97+ }
98+ val settings = BackupSettings(
99+ themeMode = settingsStore.themeMode.first().name,
100+ dynamicColor = settingsStore.dynamicColor.first(),
101+ sendOnEnter = settingsStore.sendOnEnter.first(),
102+ appLock = settingsStore.appLock.first(),
103+ appLockReLockSeconds = settingsStore.appLockReLockSeconds.first(),
104+ chatTextScale = settingsStore.chatTextScale.first(),
105+ codeWrap = settingsStore.codeWrap.first(),
106+ sessionSortMode = settingsStore.sessionSortMode.first(),
107+ sessionSortDescending = settingsStore.sessionSortDescending.first(),
108+ sessionShowArchived = settingsStore.sessionShowArchived.first(),
109+ preferredModelId = settingsStore.preferredModelId.first(),
110+ preferredAgentName = settingsStore.preferredAgentName.first(),
111+ compactMessageSpacing = settingsStore.compactMessageSpacing.first(),
112+ hapticsEnabled = settingsStore.hapticsEnabled.first(),
113+ reducedMotion = settingsStore.reducedMotion.first(),
114+ languageOverride = settingsStore.languageOverride.first(),
115+ notifRunComplete = settingsStore.notifRunComplete.first(),
116+ notifPermission = settingsStore.notifPermission.first(),
117+ notifError = settingsStore.notifError.first(),
118+ swipeLeftAction = settingsStore.swipeLeftAction.first(),
119+ swipeRightAction = settingsStore.swipeRightAction.first(),
120+ )
121+ val data = BackupData(
122+ servers = servers,
123+ settings = settings,
124+ pinned = sessionPrefsStore.pinned.first().toList(),
125+ archived = sessionPrefsStore.archived.first().toList(),
126+ )
127+ return json.encodeToString(data)
128+ }
129+
130+ /** Apply a backup document. Throws on malformed JSON so the caller can report it. Servers
131+ * are upserted (existing ids overwritten); settings and flags are restored best-effort.
132+ * An unknown [BackupData.version] is rejected BEFORE any writes so a newer-schema backup
133+ * can't silently mis-import or leave a partial restore (servers upserted, then a missing
134+ * required field throws mid-settings). */
135+ suspend fun import(text: String) {
136+ val data = json.decodeFromString<BackupData>(text)
137+ require(data.version in supportedVersions) {
138+ "Unsupported backup version ${data.version}; supported: $supportedVersions"
139+ }
140+ for (s in data.servers) {
141+ val username = s.username?.takeIf { it.isNotBlank() }
142+ val incomingPassword = s.password?.takeIf { it.isNotEmpty() }
143+ // A backup exported *without* passwords carries null. This is an upsert, so don't let
144+ // that null wipe a password already stored for this server id — preserve the existing
145+ // secret when the backup didn't include one (only the backup explicitly carrying a
146+ // password should overwrite it). Re-importing a password-less backup would otherwise
147+ // silently break auth for every matching server.
148+ val password = incomingPassword ?: username?.let {
149+ profileStore.resolve(
150+ ServerProfile(id = s.id, label = s.label, baseUrl = s.baseUrl, username = it),
151+ ).password
152+ }
153+ profileStore.save(
154+ ServerProfile(
155+ id = s.id,
156+ label = s.label,
157+ baseUrl = s.baseUrl,
158+ username = username,
159+ password = password,
160+ lastUsed = 0,
161+ requireHttps = s.requireHttps,
162+ certPin = s.certPin?.takeIf { it.isNotBlank() },
163+ ),
164+ )
165+ }
166+ data.settings?.let { settings ->
167+ // Parse the enum defensively (a bad name from an edited backup shouldn't abort the
168+ // import) WITHOUT wrapping the suspend setThemeMode call — wrapping it would swallow
169+ // a CancellationException and break structured concurrency.
170+ ThemeMode.entries.find { it.name == settings.themeMode }?.let { settingsStore.setThemeMode(it) }
171+ settingsStore.setDynamicColor(settings.dynamicColor)
172+ settingsStore.setSendOnEnter(settings.sendOnEnter)
173+ settingsStore.setAppLock(settings.appLock)
174+ settingsStore.setAppLockReLockSeconds(settings.appLockReLockSeconds)
175+ settingsStore.setChatTextScale(settings.chatTextScale)
176+ settingsStore.setCodeWrap(settings.codeWrap)
177+ // sessionSortMode is written below, AFTER validation against the known enum
178+ // entries — do NOT write it unconditionally here, or a garbage string from a
179+ // hand-edited/malicious backup would persist with no in-app reset path (the
180+ // validated write below only fires when the enum is found, so an earlier
181+ // unconditional write would leave the corrupt value in place when it's not).
182+ settingsStore.setSessionSortDescending(settings.sessionSortDescending)
183+ settingsStore.setSessionShowArchived(settings.sessionShowArchived)
184+ settingsStore.setPreferredModelId(settings.preferredModelId)
185+ settingsStore.setPreferredAgentName(settings.preferredAgentName)
186+ settingsStore.setCompactMessageSpacing(settings.compactMessageSpacing)
187+ settingsStore.setHapticsEnabled(settings.hapticsEnabled)
188+ settingsStore.setReducedMotion(settings.reducedMotion)
189+ // Validate the sort mode against the known enum entries before persisting, so a
190+ // garbage string from a hand-edited/malicious backup can't produce a broken sort
191+ // with no in-app reset path (the picker writes known values, but a corrupt one
192+ // would persist and leave the list in an undefined order). Mirrors the defensive
193+ // enum parsing used for themeMode and swipe actions above.
194+ soy.iko.opencode.ui.session.SessionSortMode.entries
195+ .find { it.name == settings.sessionSortMode }
196+ ?.let { settingsStore.setSessionSortMode(it.name) }
197+ // Validate the language override is a plausible ISO 639-1 code (2–3 lowercase
198+ // letters, optionally with a region/script subtag) before persisting, so a bad
199+ // string doesn't reach AppCompat's per-app language API. Empty (system default)
200+ // is always allowed.
201+ val lang = settings.languageOverride
202+ if (lang.isEmpty() || lang.matches(Regex("^[a-z]{2,3}(-[A-Za-z0-9]+)*$"))) {
203+ settingsStore.setLanguageOverride(lang)
204+ }
205+ settingsStore.setNotifRunComplete(settings.notifRunComplete)
206+ settingsStore.setNotifPermission(settings.notifPermission)
207+ settingsStore.setNotifError(settings.notifError)
208+ SwipeAction.entries.find { it.name == settings.swipeLeftAction }?.let { settingsStore.setSwipeLeftAction(it) }
209+ SwipeAction.entries.find { it.name == settings.swipeRightAction }?.let { settingsStore.setSwipeRightAction(it) }
210+ }
211+ data.pinned.forEach { sessionPrefsStore.setPinned(it, true) }
212+ data.archived.forEach { sessionPrefsStore.setArchived(it, true) }
213+ }
214+}
app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.ktnew file+323
@@ -0,0 +1,323 @@
1+package soy.iko.opencode.data.repo
2+
3+import android.content.Context
4+import android.content.pm.PackageManager
5+import android.os.Build
6+import android.util.Log
7+import androidx.core.content.pm.PackageInfoCompat
8+import kotlinx.coroutines.CoroutineScope
9+import kotlinx.coroutines.Dispatchers
10+import kotlinx.coroutines.Job
11+import kotlinx.coroutines.SupervisorJob
12+import kotlinx.coroutines.cancel
13+import kotlinx.coroutines.delay
14+import kotlinx.coroutines.flow.MutableStateFlow
15+import kotlinx.coroutines.flow.StateFlow
16+import kotlinx.coroutines.flow.asStateFlow
17+import kotlinx.coroutines.launch
18+import java.io.File
19+import java.io.PrintWriter
20+import java.io.StringWriter
21+import java.text.SimpleDateFormat
22+import java.util.Date
23+import java.util.Locale
24+import soy.iko.opencode.util.runCatchingCancellable
25+
26+/**
27+ * A self-contained crash reporter with no network or third-party backend. An uncaught
28+ * exception is written to app-private storage ([Context.filesDir]/crashes) as a plain
29+ * text report (timestamp, device/app metadata, and the full stack trace), then the
30+ * previous handler is re-invoked so the process still dies normally.
31+ *
32+ * Reports are surfaced in the in-app Diagnostics screen, where they can be viewed,
33+ * shared, or deleted. This closes the "no idea what's crashing in the field" gap
34+ * without pulling in a hosted analytics SDK.
35+ */
36+class CrashLogger private constructor(private val appContext: Context) {
37+
38+ data class CrashReport(
39+ val fileName: String,
40+ val timestamp: Long,
41+ val preview: String,
42+ )
43+
44+ private val crashDir = File(appContext.filesDir, "crashes").apply { mkdirs() }
45+ private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
46+ private val crashPrefs = appContext.getSharedPreferences("crash_logger", android.content.Context.MODE_PRIVATE)
47+
48+ private val _reports = MutableStateFlow<List<CrashReport>>(emptyList())
49+ val reports: StateFlow<List<CrashReport>> = _reports.asStateFlow()
50+
51+ @Volatile private var installed = false
52+
53+ fun install() {
54+ // Guard against double-install: a second call would capture this handler as `previous`
55+ // and chain it to itself, double-writing crash reports and double-invoking the original
56+ // handler on every crash. The singleton get() mitigates this in production, but install()
57+ // itself must be idempotent (tests, a misconfigured init) so the chain can't form.
58+ if (installed) return
59+ installed = true
60+ val previous = Thread.getDefaultUncaughtExceptionHandler()
61+ Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
62+ runCatching { writeReport(thread, throwable) }
63+ .onFailure { Log.e("CrashLogger", "Failed to write crash report", it) }
64+ previous?.uncaughtException(thread, throwable)
65+ }
66+ // Load crash reports off the main thread so startup isn't blocked by file I/O
67+ // (listFiles + reading the first line of each report). The StateFlow updates
68+ // whenever the scan completes, so the Diagnostics screen reflects the result.
69+ scope.launch { runCatchingCancellable { refresh() } }
70+ }
71+
72+ /** Monotonic guard so a slower earlier scan can't overwrite a newer one's result.
73+ * refresh() runs on the multi-threaded IO dispatcher from several call sites
74+ * (install/deleteReport/scheduleDelete/clearAll), so two scans can overlap. */
75+ private val refreshGeneration = java.util.concurrent.atomic.AtomicLong(0)
76+
77+ fun refresh() {
78+ val generation = refreshGeneration.incrementAndGet()
79+ // Trim over-capacity reports here (off the crashing thread, on the IO dispatcher) so
80+ // the UncaughtExceptionHandler path does only the single writeText it must to land the
81+ // report before the OS kills the process. Trimming on refresh keeps the cap enforced
82+ // across normal app launches (install calls refresh, and every delete/clear calls it).
83+ trimOldReports()
84+ val scanned = crashDir.listFiles { f -> f.isFile && f.name.endsWith(".txt") }
85+ ?.sortedByDescending { it.lastModified() }
86+ ?.map { f ->
87+ CrashReport(
88+ fileName = f.name,
89+ timestamp = f.lastModified(),
90+ preview = runCatching { f.useLines { it.firstOrNull() ?: "" } }.getOrDefault(""),
91+ )
92+ }
93+ .orEmpty()
94+ // Publish only if no newer refresh has started meanwhile, so a stale scan (e.g. one
95+ // that started before a delete) can't momentarily re-show a just-removed report.
96+ if (generation == refreshGeneration.get()) _reports.value = scanned
97+ }
98+
99+ fun readReport(fileName: String): String? {
100+ val file = File(crashDir, fileName).canonicalFile
101+ if (!file.path.startsWith(crashDir.canonicalPath + File.separator) || !file.isFile) return null
102+ return runCatching { file.readText() }.getOrNull()
103+ }
104+
105+ fun deleteReport(fileName: String) {
106+ scope.launch {
107+ val file = File(crashDir, fileName).canonicalFile
108+ if (file.path.startsWith(crashDir.canonicalPath + File.separator)) {
109+ file.delete()
110+ }
111+ refresh()
112+ }
113+ }
114+
115+ /** Whether any crash report is newer than the last-acknowledged timestamp — i.e. a
116+ * crash happened in a previous run that the user hasn't been prompted about yet.
117+ * Called on cold start to surface a "crashed last time" dialog. */
118+ fun hasUnacknowledgedCrash(): Boolean {
119+ val acknowledgedAt = crashPrefs.getLong(KEY_ACKNOWLEDGED_AT, 0L)
120+ return crashDir.listFiles { f -> f.isFile && f.name.endsWith(".txt") }
121+ ?.any { it.lastModified() > acknowledgedAt } == true
122+ }
123+
124+ /** Mark all current crash reports as acknowledged so the cold-start prompt doesn't
125+ * re-fire on the next launch. */
126+ fun acknowledgeCrashes() {
127+ crashPrefs.edit().putLong(KEY_ACKNOWLEDGED_AT, System.currentTimeMillis()).apply()
128+ }
129+
130+ /** Deferred deletions keyed by file name, so an Undo can cancel one before it fires. */
131+ private val pendingDeletes = java.util.concurrent.ConcurrentHashMap<String, Job>()
132+
133+ /** Files whose scheduled delete has already claimed (passed its delay and removed its
134+ * map entry) and is about to delete or has just deleted. [cancelScheduledDelete]
135+ * consults this to honor its contract: once a delete has fired, undo returns false
136+ * even if a reschedule installed a newer job in the map in the gap between the claim
137+ * and the cancel. Without this, the cancel would remove the newer job and report
138+ * "undo succeeded" while the older job's delete proceeds — contradicting the user
139+ * who tapped Undo and the Diagnostics screen that then shows the file gone. */
140+ private val claimedDeletes = java.util.concurrent.ConcurrentHashMap<String, Boolean>()
141+
142+ /**
143+ * Schedule [fileName] for deletion after [delayMs], cancellable via
144+ * [cancelScheduledDelete]. Runs on the logger's own long-lived scope — not the
145+ * Diagnostics screen's composition scope — so navigating away during the undo window
146+ * still commits the delete instead of silently dropping it. A repeated schedule for
147+ * the same name replaces the prior timer.
148+ */
149+ fun scheduleDelete(fileName: String, delayMs: Long) {
150+ val job = scope.launch {
151+ delay(delayMs)
152+ // Atomically claim the delete by removing our own map entry BEFORE deleting.
153+ // The remove is the synchronization point with cancelScheduledDelete: whichever
154+ // removes the entry first wins. If an undo raced the timer and removed it first,
155+ // this returns false and we skip the delete — so the file survives, matching the
156+ // `true` that cancel returned to the UI. Deleting first (then removing) would let
157+ // a cancel land in the gap and falsely report "undo succeeded" for a gone file.
158+ // remove(name, thisJob) also no-ops for a stale job after a reschedule.
159+ val claimed = coroutineContext[Job]?.let { pendingDeletes.remove(fileName, it) } == true
160+ if (!claimed) return@launch
161+ // Mark this delete as fired so a cancel that arrives AFTER the claim (but before
162+ // the file.delete() completes) honestly reports "already fired" instead of
163+ // "undo succeeded". A reschedule that installed a newer job in the map in that
164+ // gap would otherwise make cancelScheduledDelete remove the newer job and return
165+ // true, while this coroutine still deletes — violating the documented contract.
166+ claimedDeletes[fileName] = true
167+ val file = File(crashDir, fileName).canonicalFile
168+ if (file.path.startsWith(crashDir.canonicalPath + File.separator)) {
169+ file.delete()
170+ }
171+ refresh()
172+ // Clear the fired marker after the delete + refresh so a future scheduleDelete
173+ // for the same name (e.g. a re-created report) starts with a clean slate.
174+ claimedDeletes.remove(fileName)
175+ }
176+ pendingDeletes.put(fileName, job)?.cancel()
177+ }
178+
179+ /** Cancel a pending deferred delete (the Undo action). Returns true if it was still
180+ * pending (undo succeeded), false if the delete had already fired. */
181+ fun cancelScheduledDelete(fileName: String): Boolean {
182+ // If the delete has already claimed (passed its delay), the undo is too late —
183+ // report false even if a reschedule installed a newer job that's still in the map.
184+ if (claimedDeletes.containsKey(fileName)) return false
185+ val job = pendingDeletes.remove(fileName) ?: return false
186+ job.cancel()
187+ return true
188+ }
189+
190+ fun clearAll() {
191+ scope.launch {
192+ crashDir.listFiles { f -> f.isFile && f.name.endsWith(".txt") }?.forEach { it.delete() }
193+ refresh()
194+ }
195+ }
196+
197+ /** Deferred whole-directory clear, cancellable via [cancelScheduledClearAll] so the bulk
198+ * action gets the same undo window as a single-report delete. Runs on the logger's own
199+ * scope so it commits even if the user leaves the Diagnostics screen. */
200+ // AtomicReference (not a bare @Volatile var): the timer-fired claim must be a CAS,
201+ // not an unconditional null, else a reschedule that installed a newer job loses its
202+ // entry to the old coroutine's null and the clear runs immediately instead of after
203+ // the new delay (and cancelScheduledClearAll then falsely reports "already fired").
204+ // Mirrors scheduleDelete's ConcurrentHashMap.remove(name, job) claim.
205+ private val pendingClearAll = java.util.concurrent.atomic.AtomicReference<Job?>(null)
206+
207+ fun scheduleClearAll(delayMs: Long) {
208+ val job = scope.launch {
209+ delay(delayMs)
210+ // Atomically claim the clear by CAS-ing our own entry to null, mirroring
211+ // scheduleDelete's claim pattern: a reschedule (or cancel) that nulls first
212+ // makes this a no-op. Without the CAS, an unconditional null would wipe a
213+ // newer job installed by a reschedule.
214+ val mine = coroutineContext[Job]
215+ if (mine == null || !pendingClearAll.compareAndSet(mine, null)) return@launch
216+ crashDir.listFiles { f -> f.isFile && f.name.endsWith(".txt") }?.forEach { it.delete() }
217+ refresh()
218+ }
219+ pendingClearAll.getAndSet(job)?.cancel()
220+ }
221+
222+ /** Cancel a pending clear-all (the Undo action). Returns true if it was still pending. */
223+ fun cancelScheduledClearAll(): Boolean {
224+ val job = pendingClearAll.getAndSet(null) ?: return false
225+ job.cancel()
226+ return true
227+ }
228+
229+ fun reportCount(): Int = _reports.value.size
230+
231+ /** Cancel the internal coroutine scope so background work doesn't outlive the
232+ * process. Called from [AppContainer.shutdown]. The UncaughtExceptionHandler is
233+ * intentionally left installed — a crash during shutdown still needs to be logged. */
234+ fun shutdown() {
235+ scope.cancel()
236+ }
237+
238+ private fun writeReport(thread: Thread, throwable: Throwable) {
239+ val now = Date()
240+ val stamp = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss-SSS", Locale.US).format(now)
241+ // Append a short random suffix so two crashes in the same millisecond don't
242+ // overwrite each other.
243+ val suffix = java.lang.Long.toHexString(System.nanoTime())
244+ val file = File(crashDir, "crash-$stamp-$suffix.txt")
245+ val sw = StringWriter()
246+ PrintWriter(sw).use { pw ->
247+ pw.println("opencode-android crash report")
248+ pw.println("Time: ${SimpleDateFormat("yyyy-MM-dd HH:mm:ss z", Locale.US).format(now)}")
249+ pw.println("Thread: ${thread.name}")
250+ pw.println("App version: ${appVersion()}")
251+ pw.println("Device: ${Build.MANUFACTURER} ${Build.MODEL}")
252+ pw.println("Android: ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})")
253+ pw.println("ABI: ${Build.SUPPORTED_ABIS.joinToString()}")
254+ pw.println()
255+ pw.println(throwable.javaClass.name + ": " + throwable.message)
256+ throwable.printStackTrace(pw)
257+ }
258+ // Scrub URLs from the *entire* report. printStackTrace re-emits the exception
259+ // message (and every "Caused by:" message) verbatim, so scrubbing only the
260+ // top-level message above is futile — Ktor/OkHttp embed the full request URL
261+ // in those messages, which may contain auth or internal paths. Scrubbing the
262+ // assembled text guarantees no URL survives anywhere in the stored report.
263+ //
264+ // NOTE: trimOldReports() is intentionally NOT called here. writeReport runs on the
265+ // crashing thread inside the UncaughtExceptionHandler, where the OS gives ~5s before
266+ // SIGKILL; trimOldReports does listFiles + sortedByDescending + N deletes that could
267+ // exceed that window on a slow device with many old reports, losing THIS report. The
268+ // trim is driven by refresh() instead (IO dispatcher, off the crashing thread), so the
269+ // crash path does only the single writeText it must to preserve the report.
270+ file.writeText(scrubUrls(sw.toString()))
271+ }
272+
273+ /** Keep only the [MAX_REPORTS] most recent crash reports, deleting older ones. Runs inside
274+ * the uncaught-exception handler (the whole call is wrapped in runCatching upstream), so it
275+ * must not throw; each delete is guarded. */
276+ private fun trimOldReports() {
277+ val files = crashDir.listFiles { f -> f.isFile && f.name.endsWith(".txt") } ?: return
278+ if (files.size <= MAX_REPORTS) return
279+ files.sortedByDescending { it.lastModified() }
280+ .drop(MAX_REPORTS)
281+ .forEach { runCatching { it.delete() } }
282+ }
283+
284+ private fun appVersion(): String = runCatching {
285+ val pi = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
286+ appContext.packageManager.getPackageInfo(
287+ appContext.packageName,
288+ PackageManager.PackageInfoFlags.of(0),
289+ )
290+ } else {
291+ @Suppress("DEPRECATION")
292+ appContext.packageManager.getPackageInfo(appContext.packageName, 0)
293+ }
294+ "${pi.versionName} (${PackageInfoCompat.getLongVersionCode(pi)})"
295+ }.getOrDefault("unknown")
296+
297+ companion object {
298+ /** Cap on retained crash report files. A deterministic crash-loop (e.g. a crash on
299+ * startup) writes one report per crash with no other bound, so without this the
300+ * crashes/ dir grows without limit until the user manually clears it. */
301+ private const val MAX_REPORTS = 20
302+
303+ private const val KEY_ACKNOWLEDGED_AT = "acknowledged_at"
304+
305+ /** Regex matching http(s) URLs, shared by report writing and [scrubUrls]. */
306+ internal val SCRUB_URL_REGEX = Regex("https?://[^\\s\"']+")
307+
308+ /**
309+ * Replace every http(s) URL in [text] with `[url]`. Exposed as `internal` so the
310+ * redaction (used on the whole crash report, including exception messages and
311+ * stack traces produced by printStackTrace) can be unit-tested in isolation.
312+ */
313+ internal fun scrubUrls(text: String): String = SCRUB_URL_REGEX.replace(text, "[url]")
314+
315+ @Volatile
316+ private var instance: CrashLogger? = null
317+
318+ fun get(context: Context): CrashLogger =
319+ instance ?: synchronized(this) {
320+ instance ?: CrashLogger(context.applicationContext).also { instance = it }
321+ }
322+ }
323+}
app/src/main/java/soy/iko/opencode/data/repo/DraftStore.ktnew file+295
@@ -0,0 +1,295 @@
1+package soy.iko.opencode.data.repo
2+
3+import android.content.Context
4+import kotlinx.coroutines.CoroutineScope
5+import kotlinx.coroutines.Dispatchers
6+import kotlinx.coroutines.flow.MutableStateFlow
7+import kotlinx.coroutines.flow.StateFlow
8+import kotlinx.coroutines.flow.asStateFlow
9+import kotlinx.coroutines.flow.update
10+import android.util.Log
11+import kotlinx.coroutines.launch
12+import soy.iko.opencode.util.runCatchingCancellable
13+import java.util.concurrent.Executors
14+
15+/**
16+ * Persists the in-progress message draft for each session so it survives
17+ * back-navigation and process death. Backed by SharedPreferences because the
18+ * UI needs the initial value synchronously and drafts are low-stakes data.
19+ *
20+ * The prefs file is loaded on a background thread at construction time. Until
21+ * the load completes, [drafts] reports an empty map and [get] returns "" rather
22+ * than blocking the caller (previously a [CountDownLatch] could stall the main
23+ * thread for up to 2 seconds on a slow disk).
24+ */
25+open class DraftStore private constructor(
26+ private val appContext: Context?,
27+ private val scope: CoroutineScope?,
28+ @Suppress("unused") private val testMode: Boolean,
29+) {
30+ constructor(context: Context, scope: CoroutineScope) : this(context.applicationContext, scope, false)
31+ protected constructor() : this(null, null, true)
32+
33+ private val prefs by lazy {
34+ appContext?.getSharedPreferences("drafts", Context.MODE_PRIVATE)
35+ ?: error("No context — override methods in test subclass")
36+ }
37+
38+ /** Single-thread executor for flushDraft when prefs aren't initialized yet,
39+ * so the main thread is never blocked on disk I/O. */
40+ private val flushExecutor by lazy {
41+ Executors.newSingleThreadExecutor { r ->
42+ Thread(r, "DraftStore-flush").apply { isDaemon = true }
43+ }
44+ }
45+
46+ private val _drafts = MutableStateFlow<Map<String, String>>(emptyMap())
47+ /** The full in-memory snapshot of all drafts, updated when prefs finish loading. */
48+ open val drafts: StateFlow<Map<String, String>> = _drafts.asStateFlow()
49+
50+ /** Queued follow-up messages per session, persisted under a namespaced key so a
51+ * follow-up queued mid-run survives process death (the in-memory-only StateFlow in
52+ * ChatViewModel would otherwise lose it, along with the cleared draft). */
53+ private val _followUps = MutableStateFlow<Map<String, String>>(emptyMap())
54+
55+ private val _ready = MutableStateFlow(false)
56+ /** True once the background prefs load has completed. */
57+ open val ready: StateFlow<Boolean> = _ready.asStateFlow()
58+
59+ /** Keys written in-memory (set / cleared / removed) *before* the async prefs load
60+ * finished. For these the in-memory state is authoritative — the load must not
61+ * resurrect a persisted value the user has since cleared, which a plain putAll(current)
62+ * merge cannot express (it can add/override, but not delete). Consulted once, in the
63+ * init merge. Separate sets because drafts and follow-ups share the session-id key space
64+ * but live under different prefs keys. */
65+ // ConcurrentHashMap-backed sets: weakly-consistent iteration so the init merge's forEach
66+ // can't throw ConcurrentModificationException if a mutator adds a key concurrently.
67+ private val touchedDraftKeys: MutableSet<String> = java.util.concurrent.ConcurrentHashMap.newKeySet()
68+ private val touchedFollowUpKeys: MutableSet<String> = java.util.concurrent.ConcurrentHashMap.newKeySet()
69+
70+ private fun markDraftTouched(sessionId: String) {
71+ if (!_ready.value) touchedDraftKeys.add(sessionId)
72+ }
73+ private fun markFollowUpTouched(sessionId: String) {
74+ if (!_ready.value) touchedFollowUpKeys.add(sessionId)
75+ }
76+
77+ init {
78+ // Eagerly load the prefs file on a background thread so the first main-thread
79+ // get() doesn't block on disk I/O. The loaded snapshot is published via [_drafts]
80+ // so callers can observe readiness without blocking.
81+ if (scope != null && appContext != null) {
82+ scope.launch(Dispatchers.IO) {
83+ // runCatchingCancellable (not plain runCatching): this block runs in a
84+ // coroutine, and plain runCatching swallows CancellationException — if a
85+ // future suspend point is added here, cancellation would leave _ready
86+ // forever false and drafts stuck loading. Matches the convention used by
87+ // the sibling stores (MessageCacheStore, OutboxStore, AttachmentDraftStore).
88+ val all = runCatchingCancellable {
89+ prefs.all.mapValues { it.value as? String ?: "" }
90+ }.getOrDefault(emptyMap())
91+ // Partition persisted keys: follow-up keys carry a prefix, everything else
92+ // is a plain per-session draft.
93+ val drafts = mutableMapOf<String, String>()
94+ val followUps = mutableMapOf<String, String>()
95+ all.forEach { (k, v) ->
96+ if (k.startsWith(FOLLOWUP_PREFIX)) followUps[k.removePrefix(FOLLOWUP_PREFIX)] = v
97+ else drafts[k] = v
98+ }
99+ // Merge instead of overwriting: any in-memory writes that arrived before
100+ // the background load completed (e.g. a share-intent draft injected via
101+ // setImmediate) take priority over the persisted snapshot so they aren't
102+ // clobbered. For keys the user explicitly touched before the load finished,
103+ // drop the persisted entry first so a value they *cleared* isn't resurrected
104+ // (the cleared key is absent from `current`, so putAll alone can't remove it).
105+ _drafts.update { current ->
106+ drafts.apply {
107+ touchedDraftKeys.forEach { remove(it) }
108+ putAll(current)
109+ }
110+ }
111+ _followUps.update { current ->
112+ followUps.apply {
113+ touchedFollowUpKeys.forEach { remove(it) }
114+ putAll(current)
115+ }
116+ }
117+ _ready.value = true
118+ }
119+ } else {
120+ _ready.value = true
121+ }
122+ }
123+
124+ /** Returns the draft for [sessionId], or "" if not found or still loading. */
125+ open fun get(sessionId: String): String = _drafts.value[sessionId].orEmpty()
126+
127+ /** Returns the queued follow-up for [sessionId], or "" if none / still loading. */
128+ open fun getFollowUp(sessionId: String): String = _followUps.value[sessionId].orEmpty()
129+
130+ /** Persist a queued follow-up immediately (fire-and-forget, main-thread-safe).
131+ * Mirrors [flushDraft]: async apply() when prefs are open, else a background thread,
132+ * so a follow-up queued just before process death is flushed before the process exits.
133+ * A blank [text] clears the stored follow-up. */
134+ open fun flushFollowUp(sessionId: String, text: String) {
135+ markFollowUpTouched(sessionId)
136+ _followUps.update { current ->
137+ val updated = current.toMutableMap()
138+ if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
139+ updated
140+ }
141+ writeAsync(FOLLOWUP_PREFIX + sessionId, text)
142+ }
143+
144+ /** Persist [key]=[text] (or remove it when blank) without blocking the main thread on
145+ * first-time disk I/O — see [flushDraft] for the prefs-not-yet-open rationale. */
146+ private fun writeAsync(key: String, text: String) {
147+ val write = {
148+ runCatching {
149+ prefs.edit().apply {
150+ if (text.isBlank()) remove(key) else putString(key, text)
151+ }.apply()
152+ }.onFailure { Log.w("DraftStore", "Failed to persist $key", it) }
153+ Unit
154+ }
155+ // FIX: always submit to the single-thread flushExecutor (never apply directly) so
156+ // writes persist in submission order. Previously the post-init fast path applied
157+ // synchronously on the caller's thread, which could land BEFORE an earlier still-queued
158+ // write, persisting stale text. The task resolves the prefs lazy and calls apply().
159+ submitFlush(write, key)
160+ }
161+
162+ /** Update the in-memory draft immediately without persisting to disk.
163+ * Used when navigation must see the new value synchronously (e.g. share-intent
164+ * injection before ChatScreen composes). Call [set] afterwards to persist. */
165+ open fun setImmediate(sessionId: String, text: String) {
166+ markDraftTouched(sessionId)
167+ _drafts.update { current ->
168+ val updated = current.toMutableMap()
169+ if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
170+ updated
171+ }
172+ }
173+
174+ open suspend fun set(sessionId: String, text: String) {
175+ markDraftTouched(sessionId)
176+ _drafts.update { current ->
177+ val updated = current.toMutableMap()
178+ if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
179+ updated
180+ }
181+ // Route persistence through the single-thread flushExecutor (like the other writers)
182+ // rather than the shared IO pool, so this write can't race ahead of an earlier queued
183+ // flush and land stale. writeAsync persists sessionId=text (or removes it when blank),
184+ // which is exactly this draft's persistence.
185+ writeAsync(sessionId, text)
186+ }
187+
188+ /** Remove the draft for a session (call on session deletion to avoid orphaned entries).
189+ * Also clears any queued follow-up so a deleted session leaves nothing behind. */
190+ open suspend fun remove(sessionId: String) {
191+ markDraftTouched(sessionId)
192+ markFollowUpTouched(sessionId)
193+ _drafts.update { current ->
194+ val updated = current.toMutableMap()
195+ updated.remove(sessionId)
196+ updated
197+ }
198+ _followUps.update { current ->
199+ val updated = current.toMutableMap()
200+ updated.remove(sessionId)
201+ updated
202+ }
203+ // Submit the (combined draft + follow-up) removal to the same single-thread
204+ // flushExecutor the other writers use, so it can't race ahead of an earlier queued
205+ // write and resurrect stale text. Mirrors flushDraft's submission pattern.
206+ submitFlush(
207+ {
208+ runCatching {
209+ prefs.edit().remove(sessionId).remove(FOLLOWUP_PREFIX + sessionId).apply()
210+ }.onFailure { Log.w("DraftStore", "Failed to remove draft for $sessionId", it) }
211+ },
212+ "remove($sessionId)",
213+ )
214+ }
215+
216+ /**
217+ * Synchronously persist a draft. Intended for [ViewModel.onCleared] where the
218+ * viewModelScope is already cancelled and a suspending write can't be used.
219+ *
220+ * If the SharedPreferences file has already been opened (the normal case — the
221+ * background init load touches it shortly after construction), the write uses
222+ * [apply] (asynchronous) and returns immediately without blocking the main thread.
223+ *
224+ * If the prefs file has NOT been opened yet (a rare race where onCleared fires
225+ * before the background init), the write is dispatched to a background thread
226+ * instead of blocking the main thread on a synchronous disk read. The draft
227+ * may be lost if the process exits before the write completes, but this is
228+ * preferable to an ANR.
229+ */
230+ open fun flushDraft(sessionId: String, text: String) {
231+ markDraftTouched(sessionId)
232+ _drafts.update { current ->
233+ val updated = current.toMutableMap()
234+ if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
235+ updated
236+ }
237+ // FIX: always route the persistence through the single-thread flushExecutor so writes
238+ // land in submission order. Previously, once prefs were open this applied synchronously
239+ // on the calling (main) thread, so a newer direct write could persist BEFORE an older
240+ // still-queued write completed, clobbering the draft with stale text. The executor task
241+ // resolves the prefs lazy and uses apply() (async) exactly as before; the write may be
242+ // lost only if the process exits before the queued task runs (as previously).
243+ submitFlush(
244+ {
245+ runCatching {
246+ prefs.edit().apply {
247+ if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
248+ }.apply()
249+ }.onFailure { Log.w("DraftStore", "Failed to flush draft for $sessionId", it) }
250+ },
251+ "flushDraft($sessionId)",
252+ )
253+ }
254+
255+ /** Submit a flush task to the single-thread executor, surfacing a post-shutdown submit as a
256+ * logged warning instead of silently swallowing the RejectedExecutionException. Previously
257+ * the bare `runCatching { flushExecutor.execute { ... } }` swallowed RejectedExecutionException
258+ * after [shutdown], so the in-memory _drafts was already updated but the disk write never
259+ * landed with no signal — a draft silently lost. Logging makes the failure observable so
260+ * ordering bugs (shutdown before all VM onCleared flushes) are diagnosable. */
261+ private fun submitFlush(task: () -> Unit, label: String) {
262+ if (flushExecutor.isShutdown) {
263+ Log.w("DraftStore", "Dropping flush '$label': executor is shut down")
264+ return
265+ }
266+ runCatching { flushExecutor.execute { task() } }
267+ .onFailure { Log.w("DraftStore", "Failed to submit flush '$label'", it) }
268+ }
269+
270+ private companion object {
271+ /** Key prefix distinguishing persisted follow-ups from plain drafts in the shared
272+ * prefs file. The leading/trailing spaces can't collide with a real opencode session
273+ * id (which are non-blank, space-free tokens like "ses_..."). */
274+ const val FOLLOWUP_PREFIX = " followup "
275+ }
276+
277+ /** Shut down the background flush executor. Call from AppContainer.shutdown(). */
278+ open fun shutdown() {
279+ flushExecutor.shutdown()
280+ // Await pending flushDraft writes so drafts aren't lost if the process exits
281+ // immediately after shutdown (e.g. ANR-triggered process kill). Loop until the
282+ // executor is truly idle (or 5s elapses) rather than a single 2s wait: a slow disk
283+ // with a deep queue of keystroke-triggered flushes can exceed 2s, and dropping the
284+ // tail would lose the user's most recent edits. The JVM shutdown hook's own deadline
285+ // is the hard limit; this loop just gives the executor the best chance to drain.
286+ var waitedMs = 0L
287+ while (waitedMs < 5000L) {
288+ if (flushExecutor.isTerminated) break
289+ runCatching {
290+ flushExecutor.awaitTermination(500, java.util.concurrent.TimeUnit.MILLISECONDS)
291+ }
292+ waitedMs += 500L
293+ }
294+ }
295+}
app/src/main/java/soy/iko/opencode/data/repo/ErrorKind.ktnew file+81
@@ -0,0 +1,81 @@
1+package soy.iko.opencode.data.repo
2+
3+import io.ktor.client.plugins.ClientRequestException
4+import io.ktor.client.plugins.HttpRequestTimeoutException
5+import io.ktor.client.plugins.RedirectResponseException
6+import io.ktor.client.plugins.ResponseException
7+import io.ktor.client.plugins.ServerResponseException
8+import java.io.IOException
9+import java.net.ConnectException
10+import java.net.SocketTimeoutException
11+import java.net.UnknownHostException
12+
13+/**
14+ * User-facing categories for a thrown error. Extracted so the classification logic is
15+ * unit-testable without an Android [android.content.Context] (the mapping from a kind
16+ * to a localized string lives in [friendlyError]).
17+ */
18+enum class ErrorKind {
19+ /** Server host could not be resolved / connection refused. */
20+ NOT_REACHABLE,
21+
22+ /** Connect or read timed out. */
23+ TIMEOUT,
24+
25+ /** Server returned a 5xx response. */
26+ SERVER,
27+
28+ /** Server returned a 4xx response (auth, not-found, etc.). */
29+ CLIENT,
30+
31+ /** Any other network-level I/O failure. */
32+ NETWORK,
33+
34+ /** Anything that isn't network-related. */
35+ UNKNOWN,
36+}
37+
38+/**
39+ * Classify a throwable into an [ErrorKind] by inspecting concrete exception types rather
40+ * than string-matching class names. Unwraps [cause] chains so an exception surface-wrapped
41+ * by a library still classifies correctly.
42+ */
43+fun classifyError(t: Throwable): ErrorKind {
44+ var current: Throwable? = t
45+ // Walk a bounded cause chain so wrappers (e.g. an IllegalStateException around an
46+ // IOException) still classify by their root.
47+ var hops = 0
48+ while (current != null && hops < 8) {
49+ when (current) {
50+ is UnknownHostException, is ConnectException -> return ErrorKind.NOT_REACHABLE
51+ is SocketTimeoutException, is HttpRequestTimeoutException ->
52+ return ErrorKind.TIMEOUT
53+ is ServerResponseException -> return ErrorKind.SERVER
54+ // RedirectResponseException extends ClientRequestException, so it must be checked
55+ // first: a 3xx response is semantically distinct from a 4xx, and matching the
56+ // parent class first would make this branch unreachable dead code.
57+ is RedirectResponseException -> return ErrorKind.CLIENT
58+ is ClientRequestException -> return ErrorKind.CLIENT
59+ is IOException -> return ErrorKind.NETWORK
60+ else -> {
61+ // Any other ResponseException (unrecognized status family).
62+ if (current is ResponseException) return ErrorKind.SERVER
63+ }
64+ }
65+ current = current.cause
66+ hops++
67+ }
68+ return ErrorKind.UNKNOWN
69+}
70+
71+/** The HTTP status code carried by a client/server error, if any. */
72+fun responseStatusCode(t: Throwable): Int? {
73+ var current: Throwable? = t
74+ var hops = 0
75+ while (current != null && hops < 8) {
76+ if (current is ResponseException) return current.response.status.value
77+ current = current.cause
78+ hops++
79+ }
80+ return null
81+}
app/src/main/java/soy/iko/opencode/data/repo/FileBrowserPrefs.ktnew file+90
@@ -0,0 +1,90 @@
1+package soy.iko.opencode.data.repo
2+
3+import android.content.Context
4+import androidx.datastore.preferences.core.booleanPreferencesKey
5+import androidx.datastore.preferences.core.edit
6+import androidx.datastore.preferences.core.stringPreferencesKey
7+import androidx.datastore.preferences.preferencesDataStore
8+import kotlinx.coroutines.flow.Flow
9+import kotlinx.coroutines.flow.flowOf
10+import kotlinx.coroutines.flow.map
11+
12+private val Context.fileBrowserPrefsDataStore by preferencesDataStore(name = "file_browser_prefs")
13+
14+/** Sort axis for the file-browser directory listing. Name is always available; Size/Modified
15+ * depend on the server emitting size/mtime (otherwise they sort nulls to the bottom). */
16+enum class FileSortKey { NAME, SIZE, MODIFIED }
17+
18+/**
19+ * Persisted file-browser display prefs (folders-first, show-hidden, sort key + direction,
20+ * changed-only filter) so a user who prefers size-sort or hidden-files-visible keeps it
21+ * across restarts, not just rotation.
22+ *
23+ * `open` with a `protected` no-arg constructor so tests can subclass and override the flows
24+ * without a real DataStore (mirrors the other stores). The flow accessors are getters (not
25+ * eager initializers) and null-guard the context so the test constructor never touches disk.
26+ */
27+@Suppress("TooManyFunctions")
28+open class FileBrowserPrefs private constructor(
29+ private val appContext: Context?,
30+ @Suppress("unused") private val testMode: Boolean,
31+) {
32+ constructor(context: Context) : this(context.applicationContext, false)
33+ protected constructor() : this(null, true)
34+
35+ private val foldersFirstKey = booleanPreferencesKey("folders_first")
36+ private val showHiddenKey = booleanPreferencesKey("show_hidden")
37+ private val sortKeyPref = stringPreferencesKey("sort_key")
38+ private val sortDescKey = booleanPreferencesKey("sort_desc")
39+ private val changedOnlyKey = booleanPreferencesKey("changed_only")
40+
41+ // Folders-first is the most useful default; the toggle flips between folders-first name
42+ // sort and the server's raw order.
43+ open val foldersFirst: Flow<Boolean>
44+ get() = appContext?.fileBrowserPrefsDataStore?.data?.map { it[foldersFirstKey] ?: true }
45+ ?: flowOf(true)
46+
47+ // Default off so the listing isn't cluttered with dotfiles; the user reveals .env / .git
48+ // / .opencode on demand.
49+ open val showHidden: Flow<Boolean>
50+ get() = appContext?.fileBrowserPrefsDataStore?.data?.map { it[showHiddenKey] ?: false }
51+ ?: flowOf(false)
52+
53+ // Stored as the enum name; an unknown value falls back to NAME (forward-compatible with a
54+ // future rename/removal of an enum entry).
55+ open val sortKey: Flow<FileSortKey>
56+ get() = appContext?.fileBrowserPrefsDataStore?.data?.map { prefs ->
57+ runCatching { FileSortKey.valueOf(prefs[sortKeyPref] ?: FileSortKey.NAME.name) }
58+ .getOrDefault(FileSortKey.NAME)
59+ } ?: flowOf(FileSortKey.NAME)
60+
61+ open val sortDesc: Flow<Boolean>
62+ get() = appContext?.fileBrowserPrefsDataStore?.data?.map { it[sortDescKey] ?: false }
63+ ?: flowOf(false)
64+
65+ // Default off: the changed-only filter narrows the listing to git-tracked changes, which is
66+ // useful when reviewing a diff but not the default browsing view.
67+ open val changedOnly: Flow<Boolean>
68+ get() = appContext?.fileBrowserPrefsDataStore?.data?.map { it[changedOnlyKey] ?: false }
69+ ?: flowOf(false)
70+
71+ open suspend fun setFoldersFirst(value: Boolean) {
72+ appContext?.fileBrowserPrefsDataStore?.edit { it[foldersFirstKey] = value }
73+ }
74+
75+ open suspend fun setShowHidden(value: Boolean) {
76+ appContext?.fileBrowserPrefsDataStore?.edit { it[showHiddenKey] = value }
77+ }
78+
79+ open suspend fun setSortKey(key: FileSortKey) {
80+ appContext?.fileBrowserPrefsDataStore?.edit { it[sortKeyPref] = key.name }
81+ }
82+
83+ open suspend fun setSortDesc(value: Boolean) {
84+ appContext?.fileBrowserPrefsDataStore?.edit { it[sortDescKey] = value }
85+ }
86+
87+ open suspend fun setChangedOnly(value: Boolean) {
88+ appContext?.fileBrowserPrefsDataStore?.edit { it[changedOnlyKey] = value }
89+ }
90+}
app/src/main/java/soy/iko/opencode/data/repo/MessageCacheStore.ktnew file+151
@@ -0,0 +1,151 @@
1+package soy.iko.opencode.data.repo
2+
3+import android.content.Context
4+import android.util.Log
5+import kotlinx.coroutines.Dispatchers
6+import kotlinx.coroutines.sync.Mutex
7+import kotlinx.coroutines.sync.withLock
8+import kotlinx.coroutines.withContext
9+import kotlinx.serialization.builtins.ListSerializer
10+import soy.iko.opencode.data.model.MessageWithParts
11+import soy.iko.opencode.data.network.OpencodeJson
12+import soy.iko.opencode.util.runCatchingCancellable
13+import java.io.File
14+
15+/**
16+ * On-disk cache of a session's reduced message list, so a conversation shows instantly on
17+ * open (and remains readable while offline / before the network responds) instead of a blank
18+ * screen until `GET /session/:id/message` returns.
19+ *
20+ * One JSON file per session under filesDir, encoded with [OpencodeJson] so the same sealed
21+ * polymorphism used on the wire round-trips (unknown parts/messages are concrete
22+ * `__unknown` variants, so they encode too). Every operation is best-effort — a cache
23+ * miss or a write failure just falls back to the network — so problems never surface to
24+ * the user. No annotation processing (Room/KSP) per the project's manual-DI convention.
25+ */
26+open class MessageCacheStore private constructor(
27+ private val appContext: Context?,
28+ @Suppress("unused") private val testMode: Boolean,
29+) {
30+ constructor(context: Context) : this(context.applicationContext, false)
31+ protected constructor() : this(null, true)
32+
33+ private val serializer = ListSerializer(MessageWithParts.serializer())
34+
35+ /** Session ids are validated tokens upstream, but sanitize anyway so a stray id can't
36+ * escape the directory. */
37+ private val idRegex = Regex("[^A-Za-z0-9_-]")
38+
39+ private val dir: File? by lazy {
40+ appContext?.let { File(it.filesDir, "message_cache").apply { mkdirs() } }
41+ }
42+
43+ /** Per-profile subdirectory: two servers with a colliding session id must not share a cache
44+ * file, so entries are namespaced by profile id under [dir]. */
45+ private fun profileDir(profileId: String): File? =
46+ dir?.let { File(it, idRegex.replace(profileId, "_")).apply { mkdirs() } }
47+
48+ private fun fileFor(profileId: String, sessionId: String): File? =
49+ profileDir(profileId)?.let { File(it, idRegex.replace(sessionId, "_") + ".json") }
50+
51+ /** Per-session write lock so concurrent save/remove/load on the same file (e.g. both
52+ * panes of two-pane mode saving the same session, or a save racing a delete's remove)
53+ * can't tear or half-write the cache file. Keyed by session id; the map grows only with
54+ * the number of distinct sessions touched, and each [Mutex] is a few bytes. */
55+ private val locks = java.util.concurrent.ConcurrentHashMap<String, Mutex>()
56+ private fun lockFor(sessionId: String): Mutex = locks.getOrPut(sessionId) { Mutex() }
57+
58+ /** Sessions whose cache file has been removed by [remove]. A teardown flush of an
59+ * observer (CacheWriter.flushOnTeardown) runs under NonCancellable and can race with
60+ * remove(): both serialize on [lockFor], but their relative order is nondeterministic.
61+ * If the flush's save lands after remove, it re-creates the deleted session's cache
62+ * file, defeating the deletion (a privacy/disk-leak with no background cleanup). The
63+ * tombstone lets a post-remove save detect the deletion and skip. Keyed by
64+ * "$profileId/$sessionId" — a bare sessionId would suppress a *different* server's
65+ * saves if two servers share a colliding session id (the very case the per-profile
66+ * namespacing at [profileDir] exists to defend against). Cleared by [load] so
67+ * re-opening a session re-enables saving. Capped at [NetworkConfig.maxMessageCacheTombstones]
68+ * entries (see [remove]) to prevent unbounded growth across many deletions. */
69+ private val tombstones: MutableSet<String> =
70+ java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap())
71+
72+ private fun tombstoneKey(profileId: String, sessionId: String): String = "$profileId/$sessionId"
73+
74+ /** Load the cached messages for [sessionId] under [profileId], or empty if none / unreadable. */
75+ open suspend fun load(profileId: String, sessionId: String): List<MessageWithParts> = lockFor(sessionId).withLock {
76+ // Re-opening a session re-enables saving: clear any tombstone so a fresh conversation
77+ // write isn't suppressed by a prior deletion of the same (reused) id.
78+ tombstones.remove(tombstoneKey(profileId, sessionId))
79+ withContext(Dispatchers.IO) {
80+ val file = fileFor(profileId, sessionId)?.takeIf { it.exists() } ?: return@withContext emptyList()
81+ runCatchingCancellable { OpencodeJson.decodeFromString(serializer, file.readText()) }
82+ .getOrDefault(emptyList())
83+ }
84+ }
85+
86+ /** Persist [messages] for [sessionId] under [profileId] (an empty list deletes the file). */
87+ open suspend fun save(profileId: String, sessionId: String, messages: List<MessageWithParts>): Unit = lockFor(sessionId).withLock {
88+ // Skip if this session was deleted since the observer started: a teardown flush racing
89+ // after remove() must not re-create the file. (Held under the same per-session lock as
90+ // remove, so the tombstone is visible to a save that lost the race.)
91+ if (tombstoneKey(profileId, sessionId) in tombstones) return@withLock
92+ withContext(Dispatchers.IO) {
93+ val file = fileFor(profileId, sessionId) ?: return@withContext
94+ runCatchingCancellable {
95+ if (messages.isEmpty()) file.delete()
96+ else {
97+ // FIX: write atomically (temp file + rename) like RecentSessionsStore so a
98+ // process kill mid-write can't leave a truncated file that fails to decode,
99+ // wiping the cached conversation on the next open.
100+ val encoded = OpencodeJson.encodeToString(serializer, messages)
101+ val tmp = File(file.parentFile, file.name + ".tmp")
102+ tmp.writeText(encoded)
103+ if (!tmp.renameTo(file)) {
104+ // try/finally so a throw from file.writeText (disk full, permission) still
105+ // cleans up tmp — otherwise *.tmp files accumulate across failed writes.
106+ try {
107+ file.writeText(encoded)
108+ } finally {
109+ tmp.delete()
110+ }
111+ }
112+ }
113+ }.onFailure { Log.w("MessageCacheStore", "Failed to cache messages for $sessionId", it) }
114+ Unit
115+ }
116+ }
117+
118+ /** Remove a session's cached messages (call on deletion). Deletes only the calling
119+ * profile's cache file — iterating every profile namespace would nuke a *different*
120+ * server's cache for a colliding (but distinct) session id, the very case per-profile
121+ * namespacing exists to defend against. The per-session lock is intentionally NOT
122+ * evicted — a concurrent teardown save() for this id must serialize against the same
123+ * [Mutex] instance, and the map is bounded by the number of distinct sessions touched.
124+ * [profileId] namespaces the tombstone so a delete on one server doesn't suppress another
125+ * server's saves for a colliding session id. The back-compat flat file (pre-namespacing)
126+ * is also cleared since it carries no profile namespace. */
127+ open suspend fun remove(profileId: String, sessionId: String) {
128+ lockFor(sessionId).withLock {
129+ // Set the tombstone before deleting so a concurrent/loser save sees it and skips,
130+ // instead of re-creating the file we're about to delete.
131+ tombstones.add(tombstoneKey(profileId, sessionId))
132+ // Cap the tombstone set so it doesn't grow unbounded across many session deletions.
133+ // Tombstones only protect against a racing teardown flush (which resolves within
134+ // seconds), so stale entries are useless. Clearing wholesale is safe because no
135+ // racing flush can still be alive for a session deleted long ago.
136+ if (tombstones.size > soy.iko.opencode.data.network.NetworkConfig.maxMessageCacheTombstones) {
137+ tombstones.clear()
138+ tombstones.add(tombstoneKey(profileId, sessionId))
139+ }
140+ withContext(Dispatchers.IO) {
141+ runCatchingCancellable {
142+ val name = idRegex.replace(sessionId, "_") + ".json"
143+ profileDir(profileId)?.let { File(it, name).delete() }
144+ // Back-compat: pre-namespacing caches were written flat under [dir].
145+ dir?.let { File(it, name).delete() }
146+ }
147+ Unit
148+ }
149+ }
150+ }
151+}
app/src/main/java/soy/iko/opencode/data/repo/OutboxStore.ktnew file+171
@@ -0,0 +1,171 @@
1+package soy.iko.opencode.data.repo
2+
3+import android.content.Context
4+import android.util.Log
5+import kotlinx.coroutines.Dispatchers
6+import kotlinx.coroutines.flow.MutableStateFlow
7+import kotlinx.coroutines.flow.StateFlow
8+import kotlinx.coroutines.flow.asStateFlow
9+import kotlinx.coroutines.sync.Mutex
10+import kotlinx.coroutines.sync.withLock
11+import kotlinx.coroutines.withContext
12+import kotlinx.serialization.Serializable
13+import kotlinx.serialization.decodeFromString
14+import kotlinx.serialization.encodeToString
15+import kotlinx.serialization.json.Json
16+import soy.iko.opencode.data.model.ModelRef
17+import soy.iko.opencode.util.runCatchingCancellable
18+import java.io.File
19+
20+/**
21+ * One prompt composed while offline (or with no active connection), queued to send when
22+ * connectivity returns. Tagged with the [profileId] it was composed for so a reconnect to a
23+ * *different* server doesn't misfire it against a session id that only exists on the
24+ * original server. [attachments] carry their self-contained base64 data URLs, so a queued
25+ * image survives a process restart just like a staged attachment.
26+ */
27+@Serializable
28+data class OutboxMessage(
29+ val id: String,
30+ val profileId: String,
31+ val sessionId: String,
32+ val text: String,
33+ val attachments: List<PersistedAttachment> = emptyList(),
34+ val model: ModelRef? = null,
35+ val agent: String? = null,
36+ val createdAt: Long,
37+)
38+
39+/**
40+ * A persisted send-queue: prompts the user composed while disconnected, flushed on
41+ * reconnect (see [soy.iko.opencode.di.AppContainer.observeOutbox]). Backed by one JSON file
42+ * under filesDir (not SharedPreferences) because a queued attachment's base64 payload can be
43+ * several megabytes. The in-memory [messages] StateFlow is the source of truth the UI and the
44+ * flusher observe; disk is written through under a mutex on every mutation.
45+ */
46+open class OutboxStore private constructor(
47+ private val appContext: Context?,
48+ @Suppress("unused") private val testMode: Boolean,
49+) {
50+ constructor(context: Context) : this(context.applicationContext, false)
51+ protected constructor() : this(null, true)
52+
53+ private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
54+ private val ioMutex = Mutex()
55+
56+ private val _messages = MutableStateFlow<List<OutboxMessage>>(emptyList())
57+ /** All queued messages across sessions/servers, in enqueue order. */
58+ open val messages: StateFlow<List<OutboxMessage>> = _messages.asStateFlow()
59+
60+ /** Set once the on-disk queue has been read into [_messages] (under [ioMutex]). Until this
61+ * is true, a [mutate] must read disk first — otherwise a mutation that beats [load] would
62+ * persist from an empty in-memory snapshot and overwrite (or, for a removal, delete) the
63+ * persisted queue, losing every message the user composed offline in a previous run. */
64+ private var loaded = false
65+
66+ private val file: File? by lazy {
67+ appContext?.let { File(it.filesDir, "outbox.json") }
68+ }
69+
70+ /** Read the persisted queue into [messages]. Call once at startup. Idempotent. */
71+ open suspend fun load() {
72+ ioMutex.withLock { loadLocked() }
73+ }
74+
75+ /** Read the on-disk queue into memory. MUST be called while holding [ioMutex]. Idempotent
76+ * (guarded by [loaded]) so a [mutate] that arrives before [load] can call it to guarantee
77+ * disk is read before any write, then a later [load] is a no-op. Returns true once the queue
78+ * is in memory (or the file is genuinely absent), false if an existing file couldn't be
79+ * read — the caller MUST NOT persist on false or it would overwrite the still-valid queue. */
80+ private suspend fun loadLocked(): Boolean {
81+ if (loaded) return true
82+ val f = file?.takeIf { it.exists() }
83+ if (f == null) {
84+ // A genuinely absent file is a normal empty queue: mark loaded so mutations proceed.
85+ loaded = true
86+ return true
87+ }
88+ // Only mark [loaded] AFTER a successful read. A transient read/decode failure (or a
89+ // cancelled read) of an *existing* file must not clobber the still-valid on-disk queue:
90+ // leave loaded=false and report failure so the caller skips its write and a later
91+ // mutate/load retries the read, instead of persisting an empty snapshot over the user's
92+ // offline-composed messages.
93+ val diskList = withContext(Dispatchers.IO) {
94+ runCatchingCancellable { json.decodeFromString<List<OutboxMessage>>(f.readText()) }
95+ }.getOrElse { return false }
96+ // Don't clobber items enqueued while the load was in flight: merge by id, keeping
97+ // the in-memory version on conflict (it's newer).
98+ if (_messages.value.isEmpty()) {
99+ _messages.value = diskList.sortedBy { it.createdAt }
100+ } else {
101+ val known = _messages.value.mapTo(mutableSetOf()) { it.id }
102+ val merged = (diskList.filter { it.id !in known } + _messages.value).sortedBy { it.createdAt }
103+ _messages.value = merged
104+ // The merge reconciled items enqueued while this load was in flight with the
105+ // on-disk set; write the merged set back so a later process kill can't drop the
106+ // raced enqueue. persist() doesn't take ioMutex, so this won't deadlock.
107+ persist(merged)
108+ }
109+ loaded = true
110+ return true
111+ }
112+
113+ open suspend fun enqueue(message: OutboxMessage) = mutate { current ->
114+ current.filterNot { it.id == message.id } + message
115+ }
116+
117+ open suspend fun remove(id: String) = mutate { current -> current.filterNot { it.id == id } }
118+
119+ /** Remove every queued message for [sessionId] (e.g. the user discarded them, or the
120+ * session was deleted). */
121+ open suspend fun removeForSession(sessionId: String) =
122+ mutate { current -> current.filterNot { it.sessionId == sessionId } }
123+
124+ private suspend fun mutate(transform: (List<OutboxMessage>) -> List<OutboxMessage>) {
125+ // Persist *under* the lock so concurrent mutations (e.g. the flusher removing sent
126+ // messages while the UI enqueues a new one) can't race their disk writes: without
127+ // this, the older snapshot could land last and drop/resurrect a queued prompt, or two
128+ // overlapping writeText/delete calls could tear the file. The lock serializes both the
129+ // in-memory update and the write, guaranteeing on-disk order matches memory order.
130+ ioMutex.withLock {
131+ // Ensure the persisted queue has been read into memory before mutating: a mutation
132+ // that races ahead of load() would otherwise transform an empty in-memory snapshot
133+ // and persist()/delete() the file, destroying messages queued in a previous run.
134+ val loadedOk = loadLocked()
135+ val updated = transform(_messages.value).sortedBy { it.createdAt }
136+ _messages.value = updated
137+ // Only write when the existing on-disk queue was actually read. If the read of an
138+ // existing file failed (loadedOk == false), _messages is missing whatever it held, so
139+ // persisting `updated` would overwrite the still-valid file with an incomplete
140+ // snapshot — losing the user's prior offline-composed messages. Keep the transform in
141+ // memory (so the UI reflects the new item) but leave disk untouched; loaded stays
142+ // false, so the next mutate/load retries the read and then merges + persists.
143+ if (loadedOk) persist(updated)
144+ }
145+ }
146+
147+ private suspend fun persist(list: List<OutboxMessage>) {
148+ val f = file ?: return
149+ withContext(Dispatchers.IO) {
150+ runCatchingCancellable {
151+ if (list.isEmpty()) {
152+ f.delete()
153+ } else {
154+ // FIX: write atomically (temp file + rename) like RecentSessionsStore so a
155+ // process kill mid-write can't leave a truncated file — a torn file fails to
156+ // decode and load() would then drop ALL queued offline messages.
157+ val encoded = json.encodeToString(list)
158+ val tmp = File(f.parentFile, f.name + ".tmp")
159+ tmp.writeText(encoded)
160+ if (!tmp.renameTo(f)) {
161+ try {
162+ f.writeText(encoded)
163+ } finally {
164+ tmp.delete()
165+ }
166+ }
167+ }
168+ }.onFailure { Log.w("OutboxStore", "Failed to persist outbox", it) }
169+ }
170+ }
171+}
app/src/main/java/soy/iko/opencode/data/repo/ProfileStore.ktnew file+285
@@ -0,0 +1,285 @@
1+// Jetpack Security (androidx.security:security-crypto) was deprecated wholesale in 1.1.0 with
2+// no first-party drop-in replacement; we intentionally keep using EncryptedSharedPreferences /
3+// MasterKey for the encrypted password store (on-disk-compatible, still functional). Suppress
4+// the expected deprecation warnings here so the build log stays clean until a future migration.
5+@file:Suppress("DEPRECATION")
6+
7+package soy.iko.opencode.data.repo
8+
9+import android.content.Context
10+import android.content.SharedPreferences
11+import android.util.Log
12+import androidx.datastore.preferences.core.edit
13+import androidx.datastore.preferences.core.stringPreferencesKey
14+import androidx.datastore.preferences.preferencesDataStore
15+import androidx.security.crypto.EncryptedSharedPreferences
16+import androidx.security.crypto.MasterKey
17+import soy.iko.opencode.data.model.ServerProfile
18+import soy.iko.opencode.data.network.OpencodeJson
19+import kotlinx.coroutines.Dispatchers
20+import kotlinx.coroutines.sync.Mutex
21+import kotlinx.coroutines.sync.withLock
22+import kotlinx.coroutines.flow.Flow
23+import kotlinx.coroutines.flow.flowOn
24+import kotlinx.coroutines.flow.map
25+import kotlinx.coroutines.withContext
26+import kotlinx.serialization.Serializable
27+import kotlinx.serialization.builtins.ListSerializer
28+
29+private val Context.dataStore by preferencesDataStore(name = "server_profiles")
30+
31+/**
32+ * Persists server profiles. Non-secret fields go to DataStore (as a JSON list);
33+ * the Basic-auth password is stored separately in EncryptedSharedPreferences, keyed
34+ * by profile id, and merged back in on read.
35+ */
36+open class ProfileStore private constructor(
37+ private val appContext: Context?,
38+ @Suppress("unused") private val testMode: Boolean,
39+) {
40+ constructor(context: Context) : this(context.applicationContext, false)
41+ protected constructor() : this(null, true)
42+
43+ private val profilesKey = stringPreferencesKey("profiles_json")
44+
45+ /** Guards [migrateFallbackPasswords] so it runs at most once per process, not on every
46+ * DataStore emission. The mutex serializes concurrent callers so a second collector waits
47+ * for the migration's writes to land (rather than skipping ahead and reading empty secure
48+ * prefs mid-migration); [migrationDone] is only set true *after* the writes complete. */
49+ private val migrationMutex = Mutex()
50+ @Volatile private var migrationDone = false
51+
52+ private val securePrefs: SharedPreferences? by lazy {
53+ val ctx = appContext ?: return@lazy null
54+ runCatching {
55+ val masterKey = MasterKey.Builder(ctx)
56+ .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
57+ .build()
58+ EncryptedSharedPreferences.create(
59+ ctx,
60+ "server_secrets",
61+ masterKey,
62+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
63+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
64+ )
65+ }.getOrElse {
66+ Log.e("ProfileStore", "Encrypted prefs unavailable; passwords will not be persisted", it)
67+ null
68+ }
69+ }
70+
71+ /** True when EncryptedSharedPreferences could not be initialized. */
72+ open val securePrefsUnavailable: Boolean get() = securePrefs == null
73+
74+ /** Plaintext fallback used only when encrypted prefs are unavailable, so the user
75+ * can still connect — but passwords won't survive an app reinstall. */
76+ private val fallbackPrefs: SharedPreferences by lazy {
77+ appContext!!.getSharedPreferences("server_secrets_fallback", Context.MODE_PRIVATE)
78+ }
79+
80+ private fun prefsForPasswords(): SharedPreferences =
81+ securePrefs ?: fallbackPrefs
82+
83+ /** Migrate any plaintext passwords from the fallback prefs to secure prefs,
84+ * then clear the fallback. Also reaps orphaned password keys from BOTH stores whose
85+ * `pw_<id>` matches no profile in [knownProfileIds] — a prior [delete] that raced a
86+ * process death (DataStore write landed, password removal didn't) would otherwise leave
87+ * the encrypted password stranded in `server_secrets` forever, since nothing else cleans
88+ * it up. Runs at most once per process — subsequent calls are no-ops — so DataStore
89+ * re-emissions don't repeatedly scan SharedPreferences. */
90+ private suspend fun migrateFallbackPasswords(knownProfileIds: Set<String>) {
91+ if (migrationDone) return
92+ migrationMutex.withLock {
93+ // Re-check inside the lock: a concurrent caller may have completed the migration
94+ // while we were waiting. Serializing here (rather than a bare compareAndSet that
95+ // marks done before doing the work) ensures a second collector doesn't read the
96+ // secure store before the first caller's write has populated it.
97+ if (migrationDone) return
98+ val secure = securePrefs
99+ withContext(Dispatchers.IO) {
100+ val fallbackKeys = fallbackPrefs.all.keys.filter { it.startsWith("pw_") }
101+ if (secure != null && fallbackKeys.isNotEmpty()) {
102+ // Write passwords to the secure store FIRST, then remove them from the
103+ // plaintext fallback. SharedPreferences.apply() persists to disk
104+ // asynchronously, so removing from fallback before the secure batch lands
105+ // could lose the password if the process dies between the two writes.
106+ // Writing secure first makes the worst case a harmless duplicate (both
107+ // stores hold it; the migration is idempotent) rather than data loss.
108+ secure.edit().apply {
109+ for (key in fallbackKeys) {
110+ val pw = fallbackPrefs.getString(key, null) ?: continue
111+ putString(key, pw)
112+ }
113+ }.apply()
114+ fallbackPrefs.edit().apply {
115+ for (key in fallbackKeys) remove(key)
116+ }.apply()
117+ }
118+ // Reap orphaned password keys: a prior delete whose password removal raced a
119+ // process death leaves the encrypted entry stranded forever (nothing else
120+ // cleans it up, and the id may never be reused). Keyed by pw_<id>, so an orphan
121+ // is any key whose id isn't in the current profile set. Reap from both stores
122+ // since the fallback is written alongside secure in save().
123+ reapOrphanPasswords(secure, fallbackPrefs, knownProfileIds)
124+ }
125+ // Only mark done once the writes are in the secure store's in-memory cache (apply()
126+ // updates it synchronously), so the next collector past the mutex reads real values.
127+ migrationDone = true
128+ }
129+ }
130+
131+ /** Remove `pw_<id>` keys from [secure] and [fallback] whose id matches no profile in
132+ * [knownProfileIds]. A prior [delete] whose password removal raced a process death leaves
133+ * the encrypted password stranded forever; this sweep (called once per process from
134+ * [migrateFallbackPasswords]) reaps them so the secure/fallback stores don't accumulate
135+ * orphaned credentials for deleted profiles. No-op when a store is null or has no orphans. */
136+ private fun reapOrphanPasswords(
137+ secure: SharedPreferences?,
138+ fallback: SharedPreferences,
139+ knownProfileIds: Set<String>,
140+ ) {
141+ val secureOrphans = secure?.all?.keys
142+ ?.filter { it.startsWith("pw_") && it.removePrefix("pw_") !in knownProfileIds }
143+ ?: emptyList()
144+ if (secureOrphans.isNotEmpty()) {
145+ secure!!.edit().apply { secureOrphans.forEach { remove(it) } }.apply()
146+ }
147+ val fallbackOrphans = fallback.all.keys
148+ .filter { it.startsWith("pw_") && it.removePrefix("pw_") !in knownProfileIds }
149+ if (fallbackOrphans.isNotEmpty()) {
150+ fallback.edit().apply { fallbackOrphans.forEach { remove(it) } }.apply()
151+ }
152+ }
153+
154+ /** Stored shape on DataStore (everything except the secret password). */
155+ @Serializable
156+ private data class StoredProfile(
157+ val id: String,
158+ val label: String,
159+ val baseUrl: String,
160+ val username: String? = null,
161+ val lastUsed: Long = 0,
162+ // Non-secret TLS options; default to prior behavior for profiles saved before these
163+ // fields existed (deserialization uses these defaults for missing keys).
164+ val requireHttps: Boolean = false,
165+ val certPin: String? = null,
166+ )
167+
168+ open val profiles: Flow<List<ServerProfile>> = appContext?.dataStore?.data?.map { prefs ->
169+ // Decode the stored profiles first so the migration sweep can reap orphaned password
170+ // keys (whose pw_<id> matches no stored profile) in the same once-per-process pass.
171+ val json = prefs[profilesKey]
172+ val stored = json?.let {
173+ runCatching {
174+ OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), it)
175+ }.onFailure { Log.w("ProfileStore", "Failed to decode stored profiles, ignoring", it) }
176+ .getOrDefault(emptyList())
177+ } ?: emptyList()
178+ // Migrate any orphaned plaintext passwords to secure prefs, and reap any secure-pref
179+ // password keys whose profile was deleted while the process was gone (see
180+ // [migrateFallbackPasswords] for the sweep rationale).
181+ migrateFallbackPasswords(stored.mapTo(mutableSetOf()) { it.id })
182+ val sorted = stored.sortedByDescending { it.lastUsed }
183+ // Batch all password lookups into a single IO dispatch instead of one
184+ // withContext round-trip per profile (N dispatches → 1). DataStore already
185+ // runs this map block on IO, but each passwordFor() call still pays the
186+ // suspend + redispatch overhead.
187+ val pwPrefs = prefsForPasswords()
188+ sorted.map { it.toProfile(pwPrefs) }
189+ }
190+ // The .map above runs in the *collector's* context, and every consumer collects on
191+ // viewModelScope (Main). Without this, kotlinx JSON decode + EncryptedSharedPreferences
192+ // AES decryption would run on the UI thread on each emission. flowOn moves that work to IO.
193+ ?.flowOn(Dispatchers.IO)
194+ ?: kotlinx.coroutines.flow.flowOf(emptyList())
195+
196+ private fun StoredProfile.toProfile(pwPrefs: SharedPreferences): ServerProfile {
197+ val pw = username?.let { pwPrefs.getString(passwordKey(id), null) }
198+ return ServerProfile(
199+ id = id,
200+ label = label,
201+ baseUrl = baseUrl,
202+ username = username,
203+ password = pw,
204+ lastUsed = lastUsed,
205+ requireHttps = requireHttps,
206+ certPin = certPin,
207+ )
208+ }
209+
210+ open suspend fun resolve(profile: ServerProfile): ServerProfile {
211+ val pw = if (profile.hasAuth) {
212+ withContext(Dispatchers.IO) { prefsForPasswords().getString(passwordKey(profile.id), null) }
213+ } else {
214+ null
215+ }
216+ return profile.copy(password = pw)
217+ }
218+
219+ open suspend fun save(profile: ServerProfile) {
220+ // Write the password first so that if the DataStore write fails, we're left
221+ // with an orphaned password (harmless, cleaned up on next save/delete) rather
222+ // than a profile with no password (which would break authentication).
223+ withContext(Dispatchers.IO) {
224+ prefsForPasswords().edit().apply {
225+ val pw = profile.password
226+ if (!profile.username.isNullOrBlank() && !pw.isNullOrEmpty()) {
227+ putString(passwordKey(profile.id), pw)
228+ } else {
229+ remove(passwordKey(profile.id))
230+ }
231+ }.apply()
232+ }
233+ appContext!!.dataStore.edit { prefs ->
234+ val current = prefs[profilesKey]?.let {
235+ runCatching { OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), it) }
236+ .getOrDefault(emptyList())
237+ } ?: emptyList()
238+ // If the incoming profile has lastUsed=0 (e.g. ServerEditViewModel.save()
239+ // couldn't read the existing value due to a DataStore timeout), preserve
240+ // the existing nonzero lastUsed so saving an edit doesn't reset the
241+ // profile's sort position in the server list.
242+ val preservedLastUsed = if (profile.lastUsed == 0L) {
243+ current.firstOrNull { it.id == profile.id }?.lastUsed ?: 0L
244+ } else {
245+ profile.lastUsed
246+ }
247+ val stored = StoredProfile(
248+ id = profile.id,
249+ label = profile.label,
250+ baseUrl = profile.baseUrl,
251+ username = profile.username?.takeIf { it.isNotBlank() },
252+ lastUsed = preservedLastUsed,
253+ requireHttps = profile.requireHttps,
254+ certPin = profile.certPin?.takeIf { it.isNotBlank() },
255+ )
256+ val updated = current.filterNot { it.id == profile.id } + stored
257+ prefs[profilesKey] = OpencodeJson.encodeToString(ListSerializer(StoredProfile.serializer()), updated)
258+ }
259+ }
260+
261+ open suspend fun delete(id: String) {
262+ // Delete the profile from DataStore first, then the password. If the process
263+ // dies between the two, an orphaned password remains (harmless, cleaned up on
264+ // next save/delete) rather than a profile with no password.
265+ appContext!!.dataStore.edit { prefs ->
266+ val current = prefs[profilesKey]?.let {
267+ runCatching { OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), it) }
268+ .getOrDefault(emptyList())
269+ } ?: emptyList()
270+ prefs[profilesKey] = OpencodeJson.encodeToString(
271+ ListSerializer(StoredProfile.serializer()),
272+ current.filterNot { it.id == id },
273+ )
274+ }
275+ // Clean up the password in both secure and fallback prefs so no orphaned
276+ // plaintext password lingers if secure prefs were temporarily unavailable.
277+ withContext(Dispatchers.IO) {
278+ val key = passwordKey(id)
279+ securePrefs?.edit()?.remove(key)?.apply()
280+ fallbackPrefs.edit().remove(key).apply()
281+ }
282+ }
283+
284+ private fun passwordKey(id: String) = "pw_$id"
285+}
app/src/main/java/soy/iko/opencode/data/repo/RecentModelsStore.ktnew file+80
@@ -0,0 +1,80 @@
1+package soy.iko.opencode.data.repo
2+
3+import android.content.Context
4+import kotlinx.coroutines.flow.MutableStateFlow
5+import kotlinx.coroutines.flow.StateFlow
6+import kotlinx.coroutines.flow.asStateFlow
7+
8+/**
9+ * Persists the most recently picked models so the model picker can surface them as a
10+ * "Recent" section above the full catalog. Backed by SharedPreferences because the UI
11+ * needs the initial value synchronously and the list is tiny (≤ [MAX_ENTRIES]), mirroring
12+ * the low-stakes-persistence rationale of [SearchHistoryStore]. `open` with a `protected`
13+ * no-arg constructor so tests can subclass and override without a real Context.
14+ *
15+ * Entries are stored as `providerID\modelID` composite keys (the separator is chosen so it
16+ * can't collide with provider or model ids, which are URL/path-safe identifiers).
17+ */
18+open class RecentModelsStore private constructor(
19+ private val appContext: Context?,
20+ @Suppress("unused") private val testMode: Boolean,
21+) {
22+ constructor(context: Context) : this(context.applicationContext, false)
23+ protected constructor() : this(null, true)
24+
25+ private val prefs by lazy {
26+ appContext?.getSharedPreferences("recent_models", Context.MODE_PRIVATE)
27+ ?: error("No context — override methods in test subclass")
28+ }
29+
30+ private val _entries = MutableStateFlow<List<String>>(emptyList())
31+ open val entries: StateFlow<List<String>> = _entries.asStateFlow()
32+
33+ init {
34+ if (appContext != null) {
35+ _entries.value = runCatching {
36+ prefs.getString(KEY, null)
37+ ?.takeIf { it.isNotBlank() }
38+ ?.split(SEPARATOR)
39+ ?.filter { it.isNotBlank() }
40+ ?.take(MAX_ENTRIES)
41+ }.getOrDefault(emptyList()) ?: emptyList()
42+ }
43+ }
44+
45+ /** Insert [providerId]/[modelId] at the head of the recents, deduped and capped at
46+ * [MAX_ENTRIES]. Blank ids are ignored. */
47+ open fun add(providerId: String, modelId: String) {
48+ val p = providerId.trim()
49+ val m = modelId.trim()
50+ if (p.isBlank() || m.isBlank()) return
51+ val key = "$p$SEPARATOR$m"
52+ val updated = (listOf(key) + _entries.value.filter { it != key }).take(MAX_ENTRIES)
53+ _entries.value = updated
54+ runCatching {
55+ prefs.edit().putString(KEY, updated.joinToString(SEPARATOR)).apply()
56+ }
57+ }
58+
59+ /** Split a stored composite key back into its provider/model id pair. Returns null if
60+ * the entry is malformed (shouldn't happen, but a corrupt prefs value must never crash
61+ * the picker). */
62+ fun split(entry: String): Pair<String, String>? {
63+ val parts = entry.split(SEPARATOR)
64+ return if (parts.size == 2 && parts[0].isNotBlank() && parts[1].isNotBlank())
65+ parts[0] to parts[1] else null
66+ }
67+
68+ open fun clear() {
69+ _entries.value = emptyList()
70+ runCatching { prefs.edit().remove(KEY).apply() }
71+ }
72+
73+ private companion object {
74+ const val KEY = "models"
75+ // A newline can't appear in a provider/model id (they're URL/path-safe), so it's a
76+ // safe list separator that won't collide with id contents.
77+ const val SEPARATOR = "\n"
78+ const val MAX_ENTRIES = 5
79+ }
80+}
app/src/main/java/soy/iko/opencode/data/repo/RecentSessionsStore.ktnew file+100
@@ -0,0 +1,100 @@
1+package soy.iko.opencode.data.repo
2+
3+import android.content.Context
4+import kotlinx.coroutines.Dispatchers
5+import kotlinx.coroutines.sync.Mutex
6+import kotlinx.coroutines.sync.withLock
7+import kotlinx.coroutines.withContext
8+import kotlinx.serialization.Serializable
9+import kotlinx.serialization.decodeFromString
10+import kotlinx.serialization.encodeToString
11+import kotlinx.serialization.json.Json
12+import soy.iko.opencode.util.runCatchingCancellable
13+import java.io.File
14+
15+/** A minimal session summary persisted for the home-screen widget and launcher shortcuts.
16+ * Carries the originating [profileId] so a widget tap routes back to the server that ran
17+ * the session even after the user has switched to a different connection — without it,
18+ * the session would open on whichever server is currently active and 404 or show empty. */
19+@Serializable
20+data class RecentSession(
21+ val id: String,
22+ val title: String,
23+ // Defaulted so a pre-profileId persisted file (from an older app version) still decodes:
24+ // the field is absent → default "" → the widget falls back to the active connection.
25+ val profileId: String = "",
26+)
27+
28+/**
29+ * Persists a short list of recent sessions to a small JSON file under filesDir so app-external
30+ * surfaces (the home-screen widget's [android.widget.RemoteViewsService] and the "Resume last"
31+ * launcher shortcut) can read them without a live connection or in-memory state. Both the app
32+ * and the widget's RemoteViews factory run in the same process, so a plain file is sufficient.
33+ */
34+object RecentSessionsStore {
35+
36+ private const val FILE = "recent_sessions.json"
37+ const val MAX = 8
38+
39+ private val json = Json { ignoreUnknownKeys = true }
40+
41+ /** Serializes concurrent [write]s. The synchronous [read] (called from the widget's
42+ * RemoteViewsService) can't take a suspend lock, so [write] instead swaps the file in
43+ * atomically via a temp-file rename — the reader always sees either the old or the new
44+ * complete file, never a half-written one. */
45+ private val writeMutex = Mutex()
46+
47+ private fun file(context: Context) = File(context.applicationContext.filesDir, FILE)
48+
49+ /** Synchronous read — safe to call from the widget factory (bounded, tiny file). */
50+ fun read(context: Context): List<RecentSession> {
51+ val f = file(context).takeIf { it.exists() } ?: return emptyList()
52+ return runCatching { json.decodeFromString<List<RecentSession>>(f.readText()) }
53+ .getOrDefault(emptyList())
54+ }
55+
56+ /** Persist [sessions] (capped to [MAX]); a no-op empty list writes an empty file. */
57+ suspend fun write(context: Context, sessions: List<RecentSession>) = withContext(Dispatchers.IO) {
58+ writeMutex.withLock {
59+ // runCatchingCancellable (not plain runCatching): this runs inside a coroutine, and
60+ // plain runCatching swallows CancellationException — if the caller is cancelled
61+ // mid-write, cancellation wouldn't propagate and the write could complete (or
62+ // partially complete) on a dying scope. Matches the AGENTS.md convention.
63+ runCatchingCancellable {
64+ val target = file(context)
65+ val encoded = json.encodeToString(sessions.take(MAX))
66+ val tmp = File(target.parentFile, "$FILE.tmp")
67+ tmp.writeText(encoded)
68+ // Atomic replace on POSIX filesystems (app's internal storage); the reader
69+ // never observes a torn file. renameTo can fail when the target exists on
70+ // some OEM filesystems — delete the target first so the rename is a pure
71+ // create. If that still fails, copy to a SECOND temp file and atomically
72+ // rename THAT to target — writing tmp.copyTo(target) directly would expose a
73+ // half-written target to a concurrent reader (read takes no mutex), breaking
74+ // the documented invariant. The second-rename leaves target either old or new,
75+ // never torn.
76+ if (!tmp.renameTo(target)) {
77+ target.delete()
78+ if (!tmp.renameTo(target)) {
79+ val tmp2 = File(target.parentFile, "$FILE.tmp2")
80+ tmp2.writeText(encoded)
81+ if (!tmp2.renameTo(target)) {
82+ // Truly last resort: some OEM filesystems reject even a create-rename.
83+ // Write directly to target, accepting a brief non-atomic window (a
84+ // concurrent reader could see a partially-written file). The previous
85+ // delete-then-rename here was worse: between the delete and the rename
86+ // a reader saw NO file (empty recents), and if the rename failed too,
87+ // target was left missing and tmp2 orphaned. writeText at least keeps
88+ // either the old or the new content visible, never a gap. Clean up
89+ // both temps afterwards.
90+ target.writeText(encoded)
91+ }
92+ tmp.delete()
93+ tmp2.delete()
94+ }
95+ }
96+ }
97+ Unit
98+ }
99+ }
100+}
app/src/main/java/soy/iko/opencode/data/repo/SearchHistoryStore.ktnew file+68
@@ -0,0 +1,68 @@
1+package soy.iko.opencode.data.repo
2+
3+import android.content.Context
4+import kotlinx.coroutines.flow.MutableStateFlow
5+import kotlinx.coroutines.flow.StateFlow
6+import kotlinx.coroutines.flow.asStateFlow
7+import soy.iko.opencode.data.network.NetworkConfig
8+
9+/**
10+ * Persists the most recent unique global-search queries so the search screen can
11+ * surface them as suggestions. Backed by SharedPreferences because the UI needs the
12+ * initial value synchronously and the list is tiny (≤ [MAX_ENTRIES]), mirroring the
13+ * low-stakes-persistence rationale of [DraftStore]. `open` with a `protected`
14+ * no-arg constructor so tests can subclass and override without a real Context.
15+ */
16+open class SearchHistoryStore private constructor(
17+ private val appContext: Context?,
18+ @Suppress("unused") private val testMode: Boolean,
19+) {
20+ constructor(context: Context) : this(context.applicationContext, false)
21+ protected constructor() : this(null, true)
22+
23+ private val prefs by lazy {
24+ appContext?.getSharedPreferences("search_history", Context.MODE_PRIVATE)
25+ ?: error("No context — override methods in test subclass")
26+ }
27+
28+ private val _queries = MutableStateFlow<List<String>>(emptyList())
29+ open val queries: StateFlow<List<String>> = _queries.asStateFlow()
30+
31+ init {
32+ if (appContext != null) {
33+ _queries.value = runCatching {
34+ prefs.getString(KEY, null)
35+ ?.takeIf { it.isNotBlank() }
36+ ?.split(SEPARATOR)
37+ ?.filter { it.isNotBlank() }
38+ ?.take(MAX_ENTRIES)
39+ }.getOrDefault(emptyList()) ?: emptyList()
40+ }
41+ }
42+
43+ /** Insert [query] at the head of the history, deduped and capped at [MAX_ENTRIES].
44+ * Blank or sub-min-length queries are ignored so accidental empties and stray
45+ * keystrokes don't pollute the suggestions. */
46+ open fun add(query: String) {
47+ val trimmed = query.trim()
48+ if (trimmed.isBlank() || trimmed.length < NetworkConfig.minSearchQueryLength) return
49+ val updated = (listOf(trimmed) + _queries.value.filter { it != trimmed }).take(MAX_ENTRIES)
50+ _queries.value = updated
51+ runCatching {
52+ prefs.edit().putString(KEY, updated.joinToString(SEPARATOR)).apply()
53+ }
54+ }
55+
56+ open fun clear() {
57+ _queries.value = emptyList()
58+ runCatching { prefs.edit().remove(KEY).apply() }
59+ }
60+
61+ private companion object {
62+ const val KEY = "queries"
63+ // The search field is single-line, so a newline can't appear in a stored query
64+ // and is safe to use as the list separator.
65+ const val SEPARATOR = "\n"
66+ const val MAX_ENTRIES = 10
67+ }
68+}
app/src/main/java/soy/iko/opencode/data/repo/SessionPrefsStore.ktnew file+73
@@ -0,0 +1,73 @@
1+package soy.iko.opencode.data.repo
2+
3+import android.content.Context
4+import androidx.datastore.preferences.core.edit
5+import androidx.datastore.preferences.core.stringSetPreferencesKey
6+import androidx.datastore.preferences.preferencesDataStore
7+import kotlinx.coroutines.flow.Flow
8+import kotlinx.coroutines.flow.flowOf
9+import kotlinx.coroutines.flow.map
10+
11+private val Context.sessionPrefsDataStore by preferencesDataStore(name = "session_prefs")
12+
13+/**
14+ * Local, per-session organization flags the opencode server doesn't track: **pinned**
15+ * (sorted to the top of the session list) and **archived** (hidden from the list unless the
16+ * user opts to show them). Keyed by opencode session id.
17+ *
18+ * `open` with a `protected` no-arg constructor so tests can subclass and override the flows
19+ * without a real DataStore (mirrors the other stores). The flow accessors are getters (not
20+ * eager initializers) and null-guard the context so the test constructor never touches disk.
21+ */
22+open class SessionPrefsStore private constructor(
23+ private val appContext: Context?,
24+ @Suppress("unused") private val testMode: Boolean,
25+) {
26+ constructor(context: Context) : this(context.applicationContext, false)
27+ protected constructor() : this(null, true)
28+
29+ private val pinnedKey = stringSetPreferencesKey("pinned")
30+ private val archivedKey = stringSetPreferencesKey("archived")
31+ private val mutedKey = stringSetPreferencesKey("muted")
32+
33+ open val pinned: Flow<Set<String>>
34+ get() = appContext?.sessionPrefsDataStore?.data?.map { it[pinnedKey] ?: emptySet() }
35+ ?: flowOf(emptySet())
36+
37+ open val archived: Flow<Set<String>>
38+ get() = appContext?.sessionPrefsDataStore?.data?.map { it[archivedKey] ?: emptySet() }
39+ ?: flowOf(emptySet())
40+
41+ /** Session ids the user has muted (suppress unread badge + completion notification). */
42+ open val muted: Flow<Set<String>>
43+ get() = appContext?.sessionPrefsDataStore?.data?.map { it[mutedKey] ?: emptySet() }
44+ ?: flowOf(emptySet())
45+
46+ open suspend fun setPinned(sessionId: String, pinned: Boolean) = toggle(pinnedKey, sessionId, pinned)
47+
48+ open suspend fun setArchived(sessionId: String, archived: Boolean) = toggle(archivedKey, sessionId, archived)
49+
50+ open suspend fun setMuted(sessionId: String, muted: Boolean) = toggle(mutedKey, sessionId, muted)
51+
52+ /** Drop a session from all sets — call on deletion so ids don't accumulate forever. */
53+ open suspend fun forget(sessionId: String) {
54+ val ctx = appContext ?: return
55+ ctx.sessionPrefsDataStore.edit { prefs ->
56+ prefs[pinnedKey] = (prefs[pinnedKey] ?: emptySet()) - sessionId
57+ prefs[archivedKey] = (prefs[archivedKey] ?: emptySet()) - sessionId
58+ prefs[mutedKey] = (prefs[mutedKey] ?: emptySet()) - sessionId
59+ }
60+ }
61+
62+ private suspend fun toggle(
63+ key: androidx.datastore.preferences.core.Preferences.Key<Set<String>>,
64+ sessionId: String,
65+ on: Boolean,
66+ ) {
67+ val ctx = appContext ?: return
68+ ctx.sessionPrefsDataStore.edit { prefs ->
69+ val current = prefs[key] ?: emptySet()
70+ prefs[key] = if (on) current + sessionId else current - sessionId
71+ }
72+ }
73+}
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.ktnew file+802
@@ -0,0 +1,802 @@
1+package soy.iko.opencode.data.repo
2+
3+import soy.iko.opencode.data.model.AssistantMessage
4+import soy.iko.opencode.data.model.BusEvent
5+import soy.iko.opencode.data.model.FilePromptPart
6+import soy.iko.opencode.data.model.MessageInfo
7+import soy.iko.opencode.data.model.MessagePartRemoved
8+import soy.iko.opencode.data.model.MessagePartUpdated
9+import soy.iko.opencode.data.model.MessageRemoved
10+import soy.iko.opencode.data.model.MessageUpdated
11+import soy.iko.opencode.data.model.MessageWithParts
12+import soy.iko.opencode.data.model.ModelRef
13+import soy.iko.opencode.data.model.Part
14+import soy.iko.opencode.data.model.SessionError
15+import soy.iko.opencode.data.model.SessionIdle
16+import soy.iko.opencode.data.model.StepFinishPart
17+import soy.iko.opencode.data.model.UnknownMessage
18+import soy.iko.opencode.data.network.EventStreamClient
19+import soy.iko.opencode.data.network.NetworkConfig
20+import soy.iko.opencode.data.network.OpencodeApiClient
21+import soy.iko.opencode.util.runCatchingCancellable
22+import soy.iko.opencode.util.safeExceptionSummary
23+import android.util.Log
24+import kotlinx.coroutines.CoroutineScope
25+import kotlinx.coroutines.Dispatchers
26+import kotlinx.coroutines.Job
27+import kotlinx.coroutines.NonCancellable
28+import kotlinx.coroutines.delay
29+import kotlinx.coroutines.withContext
30+import kotlinx.coroutines.channels.Channel
31+import kotlinx.coroutines.flow.Flow
32+import kotlinx.coroutines.flow.channelFlow
33+import kotlinx.coroutines.flow.consumeAsFlow
34+import kotlinx.coroutines.flow.conflate
35+import kotlinx.coroutines.flow.flowOn
36+import kotlinx.coroutines.launch
37+import kotlinx.coroutines.sync.Mutex
38+import kotlinx.coroutines.sync.withLock
39+
40+/**
41+ * Source of truth for a session's conversation. It seeds from `GET /session/:id/message`
42+ * and then reduces [BusEvent]s from the SSE stream into an ordered list of messages,
43+ * each keyed by message id with parts keyed by part id (idempotent upserts).
44+ */
45+open class SessionRepository(
46+ private val api: OpencodeApiClient,
47+ private val eventStream: EventStreamClient,
48+ // Optional on-disk cache so a conversation renders instantly on open and stays readable
49+ // offline. Null in tests (and any path that doesn't wire it), where caching is skipped.
50+ private val messageCache: MessageCacheStore? = null,
51+ // Profile the cache is namespaced under, so two servers with a colliding session id don't
52+ // share a cache file. Empty in tests / when no cache is wired.
53+ private val profileId: String = "",
54+) {
55+ open suspend fun listSessions() = api.listSessions()
56+ open suspend fun createSession(title: String? = null, directory: String? = null) =
57+ api.createSession(title, directory)
58+ open suspend fun deleteSession(id: String) = api.deleteSession(id)
59+ open suspend fun abort(sessionId: String) = api.abort(sessionId)
60+
61+ open suspend fun sendPrompt(
62+ sessionId: String,
63+ text: String,
64+ attachments: List<FilePromptPart> = emptyList(),
65+ model: ModelRef?,
66+ agent: String? = null,
67+ // Stable key for retriable/re-sendable messages (the offline outbox). See
68+ // [OpencodeApiClient.sendPrompt]. Null generates a fresh key per call.
69+ idempotencyKey: String? = null,
70+ ) = api.sendPrompt(sessionId, text, attachments, model, agent, idempotencyKey)
71+
72+ open suspend fun runCommand(
73+ sessionId: String,
74+ command: String,
75+ arguments: String = "",
76+ agent: String? = null,
77+ ) = api.runCommand(sessionId, command, arguments = arguments, agent = agent)
78+
79+ /**
80+ * A live, ordered view of [sessionId]'s messages. Begins collecting the event
81+ * stream before the initial REST load so no streamed part is missed, then reduces
82+ * events into the in-memory state. The flow is conflated so a fast burst of
83+ * streaming tokens (each publishing a fresh snapshot) never back-pressures the
84+ * event reducer — a slow collector simply sees the most recent snapshot.
85+ */
86+ /** Per-subscription helper that persists reduced snapshots to [messageCache], throttled so a
87+ * fast token stream doesn't hammer the disk, plus a NonCancellable teardown flush. Extracted
88+ * from [observeMessages] so this cache-write branching lives in its own methods. [scope] is
89+ * the channelFlow scope, so scheduled saves are torn down with the flow — the teardown flush
90+ * re-persists a trailing write the cancellation dropped. */
91+ private inner class CacheWriter(private val scope: CoroutineScope, private val sessionId: String) {
92+ private var lastWrite = 0L
93+ private var pending: Job? = null
94+ // Most recent snapshot handed to [schedule], and the most recent one whose on-disk write
95+ // completed. `saved` is assigned from inside the launched save *after* it returns, so a
96+ // save cancelled mid-IO by teardown is NOT marked saved and [flushOnTeardown] re-persists
97+ // it. Written from the save coroutines, read from the collector/teardown; the only race is
98+ // a stale read causing one redundant, idempotent re-save (atomic temp+rename makes a
99+ // duplicate write of identical data harmless) — never data loss.
100+ private var latest: List<MessageWithParts>? = null
101+ private var saved: List<MessageWithParts>? = null
102+
103+ fun schedule(snapshot: List<MessageWithParts>) {
104+ val cache = messageCache ?: return
105+ // Don't early-return on an empty snapshot: MessageCacheStore.save deletes the file
106+ // for an empty list, which is exactly what we want when a conversation becomes empty
107+ // via MessageRemoved events. Skipping here would leave stale messages cached on disk
108+ // until an explicit remove() on session deletion.
109+ latest = snapshot
110+ val now = System.currentTimeMillis()
111+ pending?.cancel()
112+ // Immediate write once the throttle window has elapsed; otherwise a trailing-edge
113+ // write so a burst that ends inside the window (a run's final tokens right after a
114+ // write) still persists its last snapshot. A newer snapshot cancels and replaces a
115+ // pending trailing write, so continuous streaming coalesces to one write per window.
116+ val delayMs = if (now - lastWrite >= NetworkConfig.messageCacheWriteThrottleMs) {
117+ lastWrite = now
118+ 0L
119+ } else {
120+ NetworkConfig.messageCacheWriteThrottleMs
121+ }
122+ pending = scope.launch {
123+ if (delayMs > 0) delay(delayMs)
124+ cache.save(profileId, sessionId, snapshot)
125+ saved = snapshot
126+ }
127+ }
128+
129+ suspend fun flushOnTeardown() {
130+ // Scheduled saves are children of [scope], so teardown cancels a pending trailing
131+ // write before its delay elapses — a run's final tokens would never reach disk. Flush
132+ // the latest un-saved snapshot under NonCancellable so it survives the cancellation.
133+ val cache = messageCache ?: return
134+ val pendingSnapshot = latest ?: return
135+ if (pendingSnapshot !== saved) {
136+ withContext(NonCancellable) { cache.save(profileId, sessionId, pendingSnapshot) }
137+ }
138+ }
139+ }
140+
141+ open fun observeMessages(sessionId: String): Flow<List<MessageWithParts>> = channelFlow {
142+ val store = MessageStore()
143+ val lock = Mutex()
144+ // Conflated dirty signal: a burst of reduce()/seed() calls coalesces into a
145+ // single snapshot+send per demand cycle. Previously every changed event called
146+ // snapshot() — allocating an O(N) copy of the whole message list — and ~97% of
147+ // those copies were immediately discarded by the outer .conflate() during fast
148+ // streaming (hundreds of tokens/sec). Marking dirty and snapshotting in one
149+ // drain loop means N events in a burst allocate ~1 snapshot, not N.
150+ val dirty = Channel<Unit>(Channel.CONFLATED)
151+
152+ // Mark the store dirty; the drain loop below snapshots and publishes. CONFLATED
153+ // collapses repeated marks into a single pending signal, so a tight burst of
154+ // events doesn't even allocate N marks.
155+ fun publish() { dirty.trySend(Unit) }
156+
157+ // Subscribe to events first so we don't miss early deltas during the initial load.
158+ launch {
159+ eventStream.events.collect { event ->
160+ if (lock.withLock { store.reduce(sessionId, event) }) publish()
161+ }
162+ }
163+
164+ // Seed generation counter, shared between the initial load and the reconnect
165+ // re-seed. Guards against a stale fetch (from an earlier reconnect, or from a slow
166+ // initial load) clobbering a newer seed. Only mutated/read under [lock].
167+ var seedGeneration = 0
168+
169+ // Message ids introduced by the on-disk cache seed below. The initial REST seed uses
170+ // these to prune ghosts: a cache-seeded message absent from the authoritative REST
171+ // snapshot was deleted server-side while the app was closed. Restricting the prune to
172+ // cache-seeded ids means live SSE messages added since subscribe (not yet indexed by
173+ // REST) are never dropped. Written/read only in this collector coroutine.
174+ var cacheSeededIds: Set<String> = emptySet()
175+
176+ // Seed from the on-disk cache first so the conversation renders instantly — and stays
177+ // readable offline — before the network responds. Guarded on generation 0 so a
178+ // reconnect re-seed that somehow raced ahead isn't clobbered; the initial REST seed
179+ // (also generation 0) then overrides this with authoritative data when it succeeds.
180+ messageCache?.let { cache ->
181+ val cached = cache.load(profileId, sessionId)
182+ if (cached.isNotEmpty()) {
183+ val changed = lock.withLock {
184+ if (seedGeneration == 0) {
185+ // Store the synthetic key for UnknownMessages (which have empty ids) so
186+ // pruneStaleCacheSeeded's messages.remove(id) hits the actual map key
187+ // the store used ("unknown-c<created>" / "unknown-h<hash>") instead of
188+ // the empty id that never matches and leaves ghost entries un-pruned.
189+ cacheSeededIds = cached.mapTo(mutableSetOf()) { store.syntheticKeyFor(it) }
190+ store.seed(cached, clearPivot = false)
191+ } else false
192+ }
193+ if (changed) publish()
194+ }
195+ }
196+
197+ // Re-seed from REST when the SSE stream reconnects after a drop. The REST fetch
198+ // is performed *outside* the lock so events arriving via SSE during the fetch are
199+ // not blocked. The seed itself is under the lock; a seed generation counter ensures
200+ // a stale fetch (from an earlier reconnect) doesn't clobber a newer one.
201+ launch {
202+ // Re-seed from REST when the SSE stream reconnects after a drop. The
203+ // hasConnectedBefore flag is NOT reset on disconnect — it stays true after
204+ // the first successful connection so a subsequent reconnect triggers the
205+ // re-seed. Resetting it on disconnect would make the condition always false
206+ // after the first cycle, turning the re-seed into dead code.
207+ //
208+ // A seed generation counter guards against a stale re-seed clobbering a
209+ // newer one: if two reconnects fire in quick succession, the older fetch
210+ // (which returns later) is discarded because its generation no longer matches.
211+ var hasConnectedBefore = false
212+ eventStream.state.collect { state ->
213+ if (state == EventStreamClient.ConnectionState.Connected) {
214+ if (hasConnectedBefore) {
215+ // Pivot before the fetch: parts already in memory predate this
216+ // snapshot and must yield to it, while parts that stream in during
217+ // the fetch (added to streamedSincePivot via reduce) outrank it.
218+ // Bump the generation under the same lock so the compare in the
219+ // initial seed sees a consistent value.
220+ val generation = lock.withLock {
221+ store.beginReseed()
222+ ++seedGeneration
223+ }
224+ val freshResult = runCatchingCancellable { api.listMessages(sessionId) }
225+ .onFailure { Log.w("SessionRepository", "Re-seed message load failed for $sessionId; relying on SSE: ${safeExceptionSummary(it)}") }
226+ val fresh = freshResult.getOrNull()
227+ var seedChanged = false
228+ lock.withLock {
229+ if (generation == seedGeneration) {
230+ // Only prune when the fetch SUCCEEDED. A failed fetch yields
231+ // fresh == null; treating null as an empty REST snapshot would
232+ // make pruneStaleCacheSeeded remove EVERY cache-seeded message
233+ // (none are in the empty retain-set), wiping the whole cached
234+ // conversation on a transient network error — the cache is the
235+ // only thing keeping the session readable until SSE repopulates.
236+ var pruned = false
237+ if (fresh != null) {
238+ // Prune cache-seeded ghosts here too, not only in the initial
239+ // REST seed. If the SSE stream connected→dropped→reconnected
240+ // before the initial listMessages returned, seedGeneration was
241+ // already >0 by the time the initial seed ran, so its prune was
242+ // skipped (the `seedGeneration == 0` guard below). Without this
243+ // prune, messages deleted server-side while the app was closed
244+ // would survive as ghosts until a future app restart where the
245+ // timing happened not to recur.
246+ pruned = store.pruneStaleCacheSeeded(cacheSeededIds, fresh)
247+ }
248+ // Merge without pruning: SSE events arriving during the
249+ // REST fetch may have added messages not yet indexed by
250+ // the REST endpoint. Pruning would silently delete them.
251+ // Stale messages from deletions during the disconnect
252+ // gap are eventually removed via MessageRemoved events
253+ // or on the next app restart.
254+ val seeded = if (fresh != null) store.seed(fresh, prune = false) else false
255+ seedChanged = pruned || seeded
256+ }
257+ }
258+ if (seedChanged) publish()
259+ }
260+ hasConnectedBefore = true
261+ }
262+ }
263+ }
264+
265+ // Load the authoritative message list concurrently with the drain loop below so the
266+ // cache seed and any early SSE-reduced parts are emitted immediately. Running it inline
267+ // here (before the drain starts) would withhold the already-reduced cached conversation
268+ // until this fetch resolved — a blank screen for the whole withRetry backoff when offline.
269+ launch {
270+ val initialResult = runCatchingCancellable { api.listMessages(sessionId) }
271+ .onFailure { Log.w("SessionRepository", "Initial message load failed for $sessionId; relying on cache/SSE: ${safeExceptionSummary(it)}") }
272+ // Apply the initial seed only if no reconnect re-seed has run yet (generation still 0).
273+ // A re-seed is always a fresher full snapshot, so if the (possibly slow) initial fetch
274+ // returns after one has applied, seeding this older REST data would clobber the newer
275+ // state — reverting a completed run to "running", regressing cost/token/part state —
276+ // until the next SSE event or reconnect healed it. Only seed on SUCCESS: on failure
277+ // (e.g. offline) keep whatever's shown (the cache seed above) rather than clearing it.
278+ val seeded = lock.withLock {
279+ val list = initialResult.getOrNull()
280+ if (seedGeneration == 0 && list != null) {
281+ // Prune ghosts first: cache-seeded messages absent from this authoritative
282+ // REST snapshot were deleted server-side while the app was closed. Without
283+ // this they'd survive restart forever (re-seeded from cache, never pruned by
284+ // the reconnect path, and re-persisted by the drain loop).
285+ val pruned = store.pruneStaleCacheSeeded(cacheSeededIds, list)
286+ val changed = store.seed(list)
287+ pruned || changed
288+ } else false
289+ }
290+ if (seeded) publish()
291+ }
292+
293+ // Single drain: one snapshot+send per conflated dirty signal. The snapshot is
294+ // taken under the lock so a reader never sees a half-reduced state; send() runs
295+ // outside the lock so a slow downstream collector can't stall the drain. This
296+ // loop also keeps the flow alive until the collector cancels; the launched jobs
297+ // above are children of this scope and are torn down with it.
298+ // Per-subscription cache writer: throttled snapshot persistence + a NonCancellable
299+ // teardown flush. `this` is the channelFlow scope, so its scheduled saves are torn down
300+ // with the flow (the teardown flush re-persists a cancelled trailing write).
301+ val cacheWriter = CacheWriter(this, sessionId)
302+ // Guarantee at least one emission even when nothing seeds the store: with no on-disk
303+ // cache, a failing initial REST load (swallowed above), and a quiet SSE stream, none of
304+ // the seed paths publish, so the flow would emit nothing at all. The sole collector
305+ // (ChatViewModel) only clears its loading spinner on an emission, so without this a
306+ // first-open of an erroring/empty session spins forever with no way to reach the
307+ // "Failed to load / Retry" affordance. CONFLATED dirty coalesces this with any seed
308+ // publish into a single send.
309+ publish()
310+ try {
311+ dirty.consumeAsFlow().collect {
312+ val snapshot = lock.withLock { store.snapshot() }
313+ send(snapshot)
314+ // Persist to the on-disk cache, throttled so a fast token stream doesn't hammer
315+ // the disk — the cache only needs to be "recent enough" for an instant/offline
316+ // first paint; the network corrects it on the next open.
317+ cacheWriter.schedule(snapshot)
318+ }
319+ } finally {
320+ cacheWriter.flushOnTeardown()
321+ }
322+ // Run the whole reducer pipeline — event reduction, list rebuilds, and the O(N)
323+ // snapshot copy — on Dispatchers.Default rather than the collector's context. The
324+ // sole consumer collects via stateIn(viewModelScope), i.e. Main.immediate, so without
325+ // this every streamed token's reduce()/snapshot() would execute on the UI thread
326+ // (hundreds/sec on a fast run). The seed REST calls here only suspend, so moving them
327+ // off Main is harmless. JSON decoding already runs off-Main upstream in EventStreamClient.
328+ }.flowOn(Dispatchers.Default).conflate()
329+
330+ companion object {
331+ /** Convenience: is this event a run-completion signal for [sessionId]?
332+ * A null sessionID is NOT treated as a wildcard — an idle event with no
333+ * session id must not reset the running state of every open chat. */
334+ fun isIdle(event: BusEvent, sessionId: String): Boolean =
335+ event is SessionIdle && event.properties.sessionID == sessionId
336+
337+ fun isError(event: BusEvent, sessionId: String): Boolean =
338+ event is SessionError && event.properties.sessionID == sessionId
339+
340+ /** Does this event indicate an in-progress run for [sessionId]? A live streamed part or
341+ * an assistant message that isn't yet complete/errored means the agent is still working.
342+ * Used to restore the "running" indicator after an SSE reconnect: if the run is still
343+ * going, such live events keep arriving; a run that finished during the outage produces
344+ * none (its completion arrives via the REST re-seed, not the event stream). */
345+ fun isRunActivity(event: BusEvent, sessionId: String): Boolean = when (event) {
346+ // The session id rides on the part for message.part.updated; properties.sessionID
347+ // is null on the wire. Mirror reduce()'s `part.sessionID ?: properties.sessionID`
348+ // so a run that's only streaming parts still re-lights the indicator on reconnect.
349+ // A step-finish part is the trailing completion marker for a run (it carries the
350+ // final cost/token totals) and arrives after the last stream delta. Treating it as
351+ // live activity would re-light the running indicator after a reconnect when the run
352+ // already finished during the outage — with no following SessionIdle to clear it,
353+ // pinning the spinner (and the foreground service) indefinitely. Mirrors
354+ // AppContainer.isLiveRunActivity's exclusion.
355+ is MessagePartUpdated -> {
356+ val part = event.properties.part
357+ part !is StepFinishPart &&
358+ (part.sessionID ?: event.properties.sessionID) == sessionId
359+ }
360+ is MessageUpdated -> {
361+ val info = event.properties.info ?: return false
362+ info.sessionID == sessionId && info is AssistantMessage && !info.isComplete && info.error == null
363+ }
364+ else -> false
365+ }
366+ }
367+}
368+
369+/**
370+ * In-memory reduction state for one observed session. Not thread-safe; guard with a Mutex.
371+ * Exposed as `internal` so the reducer logic can be unit-tested directly.
372+ */
373+internal class MessageStore {
374+ // messageId -> mutable holder (info + insertion-ordered parts); the map itself is
375+ // insertion-ordered.
376+ private val messages = LinkedHashMap<String, Holder>()
377+
378+ /**
379+ * Mutable per-message holder. Parts live in an insertion-ordered map keyed by part id, so a
380+ * streamed part update is an O(1) put instead of an O(P) list rebuild. The immutable
381+ * [MessageWithParts] view is materialized lazily and cached until the next mutation — so a
382+ * burst of streamed token updates rebuilds a message's part list at most once per published
383+ * [snapshot], not once per token. The cached view is also reference-stable across snapshots
384+ * while unchanged, so Compose skips recomposing bubbles that didn't change.
385+ */
386+ private class Holder(var info: MessageInfo, val parts: LinkedHashMap<String, Part>) {
387+ private var cached: MessageWithParts? = null
388+ fun view(): MessageWithParts =
389+ cached ?: MessageWithParts(info, parts.values.toList()).also { cached = it }
390+ /** Adopt an existing immutable instance as the cache — avoids an immediate rebuild and
391+ * preserves its identity (e.g. when seeding an authoritative REST snapshot). */
392+ fun adopt(m: MessageWithParts) { cached = m }
393+ fun invalidate() { cached = null }
394+ }
395+
396+ /** Build a [Holder] from an immutable message, reusing [m] itself as the cached view. */
397+ private fun holderOf(m: MessageWithParts): Holder {
398+ val parts = LinkedHashMap<String, Part>(m.parts.size)
399+ for (p in m.parts) parts[p.id] = p
400+ return Holder(m.info, parts).also { it.adopt(m) }
401+ }
402+
403+ /** Part ids upserted via [reduce] since the last reseed pivot ([beginReseed]) or seed.
404+ * On a reconnect re-seed these are the parts that streamed in *during* the REST fetch,
405+ * so they're newer than the snapshot and must win over it; every other part is taken
406+ * from the authoritative REST snapshot. Without this distinction the merge kept every
407+ * overlapping in-memory part, so a part that changed server-side during the disconnect
408+ * (e.g. a tool going running -> completed) stayed stale until app restart. */
409+ private val streamedSincePivot = mutableSetOf<String>()
410+
411+ /** Message ids whose `info` was updated via a live SSE [reduce] since the last reseed
412+ * pivot ([beginReseed]) or seed. Parallels [streamedSincePivot] but for message info:
413+ * on a reconnect re-seed these are the messages whose info changed *during* the REST
414+ * fetch, so the in-memory info is newer than the snapshot and must win; every other
415+ * message's info is taken from the authoritative REST snapshot. Without this the merge
416+ * kept the in-memory info, so a run that finished server-side during the disconnect
417+ * (e.g. cost/token totals, completion time) stayed stale until app restart. */
418+ private val messageInfoUpdatedSincePivot = mutableSetOf<String>()
419+
420+ /** Mark the point a reconnect re-seed's REST fetch begins: parts/info streamed from here
421+ * on are newer than the fetched snapshot. Call under the same lock as [seed]/[reduce]. */
422+ fun beginReseed() {
423+ streamedSincePivot.clear()
424+ messageInfoUpdatedSincePivot.clear()
425+ }
426+
427+ /** Maximum number of messages to keep in memory; oldest are evicted when exceeded. */
428+ internal val maxMessages = NetworkConfig.maxInMemoryMessages
429+
430+ fun snapshot(): List<MessageWithParts> = messages.values.map { it.view() }
431+
432+ fun seed(initial: List<MessageWithParts>, prune: Boolean = false, clearPivot: Boolean = true): Boolean {
433+ var changed = false
434+ // seed() does NOT prune by default. Pruning is intentionally NOT wired into the
435+ // reconnect re-seed (the only path that would want it): the live SSE stream — via
436+ // MessageRemoved events — drives deletions, and pruneStaleCacheSeeded() handles
437+ // ghosts from the on-disk cache on restart. Pruning on re-seed would race with
438+ // just-arrived SSE events whose messages may not yet be indexed by REST, dropping
439+ // live messages. The `prune = true` branch below is retained as a tested capability
440+ // for callers that can guarantee no concurrent SSE (e.g. a future explicit
441+ // "discard and reload from REST" entry point); the current production re-seed
442+ // callsite passes `prune = false` for the race reason above.
443+ if (prune) {
444+ // Build the retain-set from the synthetic key (matching the map's keying for
445+ // UnknownMessages with empty ids) so an entry stored under "unknown-c<created>"
446+ // isn't wrongly evicted and re-inserted under "" — which would lose its
447+ // insertion-order position and force a spurious snapshot emission.
448+ val incomingKeys = initial.mapTo(mutableSetOf()) { syntheticKeyFor(it) }
449+ val before = messages.size
450+ messages.keys.retainAll(incomingKeys)
451+ if (messages.size != before) changed = true
452+ }
453+ // An authoritative seed supersedes any eviction record: a message re-introduced by
454+ // this seed is back in memory, so a later MessagePartUpdated should update it, not
455+ // be dropped as "late for an evicted message". Clearing keeps the set bounded too.
456+ evictedMessageIds.clear()
457+
458+ for (m in initial) {
459+ val key = syntheticKeyFor(m)
460+ val existing = messages[key]
461+ if (existing == null) {
462+ messages[key] = holderOf(m)
463+ changed = true
464+ } else {
465+ // A part streamed in between subscribe and this initial load may already
466+ // have populated this message (see observeMessages). Merge instead of
467+ // overwriting so that newer streamed part isn't discarded: take the
468+ // snapshot's part order, swap in the streamed version where ids overlap,
469+ // append any streamed-only parts, and adopt the authoritative REST info.
470+ val streamedById = existing.parts
471+ val snapshotIds = m.parts.mapTo(mutableSetOf()) { it.id }
472+ // Prefer the in-memory version of an overlapping part ONLY if it streamed
473+ // in since the reseed pivot (i.e. during this fetch), so it's newer than
474+ // the snapshot. Otherwise the REST snapshot is authoritative and wins,
475+ // discarding an in-memory part that went stale during a disconnect.
476+ val ordered = m.parts.map { p ->
477+ if (p.id in streamedSincePivot) streamedById[p.id] ?: p else p
478+ }.toMutableList()
479+ // Append only parts that streamed in *since the pivot* and are absent from the
480+ // snapshot — i.e. genuinely newer than this REST fetch. A part in memory from
481+ // before the pivot that the snapshot omits was removed server-side; the
482+ // authoritative REST snapshot must be allowed to drop it, else it reappears as
483+ // a ghost and gets baked into the on-disk cache forever.
484+ for (p in existing.parts.values) {
485+ if (p.id !in snapshotIds && p.id in streamedSincePivot) ordered.add(p)
486+ }
487+ // Adopt the authoritative REST info unless the in-memory info was itself
488+ // updated live since the pivot (i.e. during this fetch), in which case it's
489+ // newer than the snapshot. Otherwise the REST snapshot wins. The pivot set is
490+ // keyed by the same synthetic key used in the map (see handleMessageUpdated),
491+ // so test that here instead of m.info.id — which is "" for an UnknownMessage
492+ // and would never match, leaving the pivot protection inert for that case.
493+ val info = if (existing.info is UnknownMessage || key !in messageInfoUpdatedSincePivot) m.info else existing.info
494+ val merged = MessageWithParts(info = info, parts = ordered)
495+ if (merged != existing.view()) {
496+ // Store under `key` (the synthetic key for an empty-id UnknownMessage),
497+ // not `m.info.id` (which is "" for such a message and would collide).
498+ messages[key] = holderOf(merged)
499+ changed = true
500+ }
501+ }
502+ }
503+ // A part that streamed in *before* this initial load (observeMessages subscribes to
504+ // SSE before fetching) inserts its message at the front of the insertion-ordered map,
505+ // and the merge above keeps that position — pinning a running message above the older
506+ // history that seed() inserts afterward. Restore chronological order by creation time
507+ // FIRST, so eviction below drops the genuinely-oldest entries: evictOldMessages()
508+ // removes from the map's front, which before reordering is the newest streamed message.
509+ if (reorderByTime()) changed = true
510+ if (evictOldMessages()) changed = true
511+ // Reset the pivot: everything just merged is now the baseline, so only parts/info
512+ // that stream in *after* this seed can outrank the next reconnect's snapshot. The
513+ // on-disk cache seed passes clearPivot = false: it runs *before* the authoritative
514+ // initial REST seed, so clearing here would strip pivot protection from SSE parts/info
515+ // that streamed in since subscribe, letting that REST seed regress them.
516+ if (clearPivot) {
517+ streamedSincePivot.clear()
518+ messageInfoUpdatedSincePivot.clear()
519+ }
520+ return changed
521+ }
522+
523+ /** Remove messages that were seeded from the on-disk cache ([cacheSeededIds]) but are
524+ * absent from the authoritative REST snapshot [rest] — i.e. deleted server-side while the
525+ * app was closed. Restricted to cache-seeded ids so a message added by a live SSE event
526+ * since subscribe (which the REST endpoint may not have indexed yet) is never pruned.
527+ * This is what actually removes ghosts on restart; the reconnect re-seed deliberately
528+ * keeps prune=false to avoid racing just-arrived SSE. Call under the same lock as [seed].
529+ * Returns true if anything was removed. */
530+ fun pruneStaleCacheSeeded(cacheSeededIds: Set<String>, rest: List<MessageWithParts>): Boolean {
531+ if (cacheSeededIds.isEmpty()) return false
532+ // Build the retain-set from the synthetic key (matching the map's keying for
533+ // UnknownMessages with empty ids) so an entry stored under "unknown-c<created>" isn't
534+ // wrongly pruned just because its raw id ("") isn't in the REST snapshot's raw-id set.
535+ val restIds = rest.mapTo(mutableSetOf()) { syntheticKeyFor(it) }
536+ var changed = false
537+ for (id in cacheSeededIds) {
538+ if (id !in restIds && messages.remove(id) != null) changed = true
539+ }
540+ return changed
541+ }
542+
543+ /** Resolve the map key for a seed entry. An UnknownMessage with an empty id (from an
544+ * unrecognized server role with no id field) was stored by handleMessageUpdated under a
545+ * synthetic key ("unknown-c<created>" or "unknown-h<hash>"); derive the same key here so
546+ * a REST seed merges onto the existing holder instead of missing it and inserting a
547+ * duplicate under key "". */
548+ internal fun syntheticKeyFor(m: MessageWithParts): String =
549+ if (m.info.id.isEmpty() && m.info is UnknownMessage) {
550+ m.info.time?.created?.let { "unknown-c$it" } ?: "unknown-h${m.info.hashCode()}"
551+ } else {
552+ m.info.id
553+ }
554+
555+ /** Resolve the actual map key for a raw message id from a server event. A non-empty id is
556+ * the map key as-is. An empty id ("") can't be the map key (handleMessageUpdated stores
557+ * UnknownMessages with empty ids under a synthetic "unknown-c<created>" / "unknown-h<hash>"
558+ * key), so search the map for the holder whose info is an UnknownMessage with an empty id.
559+ * Returns null when no holder matches — callers treat this as "not in the store" and skip.
560+ *
561+ * This is O(N) only for the rare empty-id case (UnknownMessages from unrecognized server
562+ * roles); the common non-empty-id path is an O(1) map lookup. Without this, handleMessageRemoved
563+ * / handlePartRemoved / the null-session MessagePartUpdated guard would all miss synthetic-keyed
564+ * holders, leaving ghost entries and dropped part removals. */
565+ private fun resolveMapKey(messageId: String): String? {
566+ if (messageId.isNotEmpty()) return messageId
567+ // Empty id: find a holder stored under a synthetic key whose info is an UnknownMessage
568+ // with an empty id. There's normally at most one such holder per session (an unrecognized
569+ // server role with no id is rare), so the linear scan is acceptable.
570+ return messages.entries.firstOrNull { entry ->
571+ entry.value.info is UnknownMessage && entry.value.info.id.isEmpty()
572+ }?.key
573+ }
574+
575+ /** Reorder the message map by message creation time (stable). Ties and entries with no
576+ * server time yet (e.g. a brand-new message still streaming and not in REST) keep their
577+ * relative order, so such a message stays last. Preserves each entry's map key so the
578+ * synthetic UnknownMessage keys aren't collapsed. Returns true if the order changed. */
579+ private fun reorderByTime(): Boolean {
580+ val entries = messages.entries.map { it.key to it.value }
581+ val sorted = entries.sortedBy { it.second.info.time?.created ?: Long.MAX_VALUE }
582+ // Detect a reordering by comparing keys positionally, instead of allocating two more
583+ // N-sized key lists just to compare them.
584+ var changed = false
585+ for (i in sorted.indices) {
586+ if (sorted[i].first != entries[i].first) { changed = true; break }
587+ }
588+ if (!changed) return false
589+ messages.clear()
590+ for ((k, v) in sorted) messages[k] = v
591+ return true
592+ }
593+
594+ /** Returns true if the state changed (and a new snapshot should be published). */
595+ fun reduce(sessionId: String, event: BusEvent): Boolean {
596+ return when (event) {
597+ is MessageUpdated -> handleMessageUpdated(sessionId, event)
598+
599+ is MessagePartUpdated -> {
600+ val part = event.properties.part
601+ val rawMessageId = part.messageID ?: event.properties.messageID
602+ val partSession = part.sessionID ?: event.properties.sessionID
603+ when {
604+ rawMessageId == null -> false
605+ // Resolve the map key: an UnknownMessage with an empty id is stored under a
606+ // synthetic key, so upsertPart(rawMessageId, part) would miss it and fork a
607+ // duplicate holder under key "". resolveMapKey returns the synthetic key when
608+ // the holder exists, or the raw id (possibly "") when it doesn't — in which
609+ // case upsertPart creates a fresh holder under that id, matching the prior
610+ // behavior for a genuinely new message.
611+ else -> {
612+ val mapKey = resolveMapKey(rawMessageId) ?: rawMessageId
613+ if (partSession != null) {
614+ if (partSession == sessionId) upsertPart(mapKey, part) else false
615+ } else {
616+ // No session id anywhere on the event: a null session is NOT a wildcard
617+ // (mirroring isIdle/isError). Every open session's store sees the shared
618+ // event stream, so accepting it unconditionally would leak the part into
619+ // unrelated conversations (e.g. both panes in two-pane mode). Attribute it
620+ // only if this store already holds the parent message — MessageUpdated,
621+ // which always carries a session id, will have created it here first.
622+ if (messages.containsKey(mapKey)) upsertPart(mapKey, part) else false
623+ }
624+ }
625+ }
626+ }
627+
628+ is MessagePartRemoved -> handlePartRemoved(sessionId, event)
629+
630+ is MessageRemoved -> handleMessageRemoved(sessionId, event)
631+
632+ else -> false
633+ }
634+ }
635+
636+ private fun handleMessageUpdated(sessionId: String, event: MessageUpdated): Boolean {
637+ val info = event.properties.info ?: return false
638+ if (info.sessionID != sessionId) return false
639+ // An UnknownMessage with an empty id (e.g. from an unrecognized server role with no id
640+ // field) would collide with other such messages in the map. It carries no id, only
641+ // sessionID/time, so derive a synthetic key. Prefer the immutable creation timestamp: it's
642+ // stable across updates, so a later update to the SAME unknown message (whose `time.updated`
643+ // or `time.completed` changes) coalesces onto its holder via the existing==info guard below
644+ // instead of forking a duplicate — the failure mode a full-content hash has, since any
645+ // content change moves the hash. Distinct unknown messages still separate by their distinct
646+ // creation times; only when even `created` is absent do we fall back to the content hash.
647+ val key = if (info.id.isEmpty() && info is UnknownMessage) {
648+ info.time?.created?.let { "unknown-c$it" } ?: "unknown-h${info.hashCode()}"
649+ } else {
650+ info.id
651+ }
652+ val existing = messages[key]
653+ // A server re-sending byte-identical info is a no-op: skip it so we don't publish a
654+ // redundant snapshot (mirrors upsertPart's identical-part guard).
655+ if (existing != null && existing.info == info) return false
656+ // A MessageUpdated re-introduces the message into the store (it was either evicted
657+ // by evictOldMessages or genuinely new). Drop any eviction record so a following
658+ // MessagePartUpdated updates this holder instead of being dropped as "late".
659+ info.id.takeIf { it.isNotEmpty() }?.let { evictedMessageIds.remove(it) }
660+ if (existing == null) {
661+ messages[key] = Holder(info, LinkedHashMap())
662+ } else {
663+ existing.info = info
664+ existing.invalidate()
665+ }
666+ // Record that this message's info streamed in live, so a re-seed after a reconnect knows
667+ // it's newer than the REST snapshot and keeps it (see seed()/beginReseed()).
668+ messageInfoUpdatedSincePivot.add(key)
669+ if (existing == null) evictOldMessages()
670+ return true
671+ }
672+
673+ private fun handlePartRemoved(sessionId: String, event: MessagePartRemoved): Boolean {
674+ val rawMessageId = event.properties.messageID ?: return false
675+ val partId = event.properties.partID ?: return false
676+ val eventSession = event.properties.sessionID
677+ // Resolve the actual map key: an UnknownMessage with an empty id is stored under a
678+ // synthetic key, so a bare messages.containsKey(rawMessageId) would miss it and the
679+ // part removal would be silently dropped.
680+ val mapKey = resolveMapKey(rawMessageId) ?: return false
681+ when {
682+ eventSession != null && eventSession != sessionId -> return false
683+ // Null session id: only act if this store already holds the parent message, so a
684+ // null-session part removal can't drop a part from an unrelated session's store.
685+ eventSession == null && !messages.containsKey(mapKey) -> return false
686+ }
687+ return removePart(mapKey, partId)
688+ }
689+
690+ private fun handleMessageRemoved(sessionId: String, event: MessageRemoved): Boolean {
691+ val rawId = event.properties.messageID ?: return false
692+ val eventSession = event.properties.sessionID
693+ // Resolve the actual map key: an UnknownMessage with an empty id is stored under a
694+ // synthetic key ("unknown-c<created>" / "unknown-h<hash>"), so messages.remove(rawId)
695+ // would miss it and leave the ghost entry stranded — the pivotKey cleanup below would
696+ // be dead code (the early return fires before it runs).
697+ val id = resolveMapKey(rawId) ?: return false
698+ when {
699+ eventSession != null && eventSession != sessionId -> return false
700+ // Null session id: only act if this store already holds the message (message ids
701+ // are globally unique, so this attributes the removal to the owning session only).
702+ eventSession == null && !messages.containsKey(id) -> return false
703+ }
704+ val removed = messages.remove(id) ?: return false
705+ // The message is gone from the store; drop any eviction record for it so the set
706+ // doesn't retain ids of long-deleted messages. (A re-seed would also clear this,
707+ // but a quiet session that never reconnects could otherwise accumulate entries.)
708+ removed.info.id.takeIf { it.isNotEmpty() }?.let { evictedMessageIds.remove(it) }
709+ // Derive the same key handleMessageUpdated would have stored this holder under. For an
710+ // UnknownMessage with an empty id, that's the synthetic "unknown-c<created>" /
711+ // "unknown-h<hash>" key (not the raw empty id) — removing the raw id here would leave
712+ // the synthetic entry stranded in messageInfoUpdatedSincePivot forever (no correctness
713+ // impact today — seed() only consults it when the holder exists — but unbounded growth
714+ // across many add/remove cycles in a long-lived session). Mirrors evictOldMessages,
715+ // which already uses the map key (oldestKey) for its pivot cleanup.
716+ val pivotKey = if (rawId.isEmpty() && removed.info is UnknownMessage) {
717+ removed.info.time?.created?.let { "unknown-c$it" } ?: "unknown-h${removed.info.hashCode()}"
718+ } else {
719+ id
720+ }
721+ messageInfoUpdatedSincePivot.remove(pivotKey)
722+ removed.parts.keys.forEach { streamedSincePivot.remove(it) }
723+ return true
724+ }
725+
726+ private fun upsertPart(messageId: String, part: Part): Boolean {
727+ val current = messages[messageId]
728+ if (current != null) {
729+ // O(1) map lookup + put, vs the former O(P) indexOfFirst scan and full-list copy
730+ // on every streamed token. A LinkedHashMap re-put keeps the part's original
731+ // position (replace-in-place); a new id is appended — matching the old list order.
732+ if (current.parts[part.id] == part) return false
733+ current.parts[part.id] = part
734+ current.invalidate()
735+ } else {
736+ // A late part update for a message that was evicted by evictOldMessages() (the
737+ // store caps at maxMessages) would otherwise re-insert the message with only
738+ // this single part — a partial, contextless bubble at the end of the list. The
739+ // evicted message's other parts are gone from memory, so re-inserting can't
740+ // restore them; ignore the late part instead. (A later MessageUpdated for the
741+ // same id — which always carries full info — re-creates the holder correctly.)
742+ if (evictedMessageIds.contains(messageId)) return false
743+ val parts = LinkedHashMap<String, Part>()
744+ parts[part.id] = part
745+ messages[messageId] = Holder(
746+ info = UnknownMessage(id = messageId, sessionID = part.sessionID ?: ""),
747+ parts = parts,
748+ )
749+ }
750+ // Record that this part streamed in, so a re-seed after a reconnect knows it's
751+ // newer than the REST snapshot and keeps it (see seed()/beginReseed()).
752+ streamedSincePivot.add(part.id)
753+ if (current == null) evictOldMessages()
754+ return true
755+ }
756+
757+ private fun removePart(messageId: String, partId: String): Boolean {
758+ val current = messages[messageId] ?: return false
759+ if (current.parts.remove(partId) == null) return false
760+ // Drop the pivot entry too so the set doesn't accumulate ids of removed parts for
761+ // the rest of the session (it's otherwise only cleared on reseed/eviction). No
762+ // correctness impact today — a stale entry just falls back to the REST snapshot's
763+ // version in seed() — but keeping it symmetric with handleMessageRemoved/eviction
764+ // bounds the set's growth over a long-lived session.
765+ streamedSincePivot.remove(partId)
766+ current.invalidate()
767+ return true
768+ }
769+
770+ /** Message ids evicted by [evictOldMessages] (capped at [maxMessages]). A late
771+ * [MessagePartUpdated] for one of these would otherwise re-insert the message under
772+ * [upsertPart] with only the late part — a partial, contextless bubble at the end of
773+ * the list. Membership here tells upsertPart to drop the late part instead. Bounded by
774+ * the same cap (entries are removed when the message is re-seeded via [seed] or removed
775+ * via [handleMessageRemoved]); a long-lived session that cycles past maxMessages many
776+ * times never grows this beyond the most recent eviction batch. */
777+ private val evictedMessageIds = mutableSetOf<String>()
778+
779+ /** Evict the oldest messages when the store exceeds [maxMessages], keeping memory bounded.
780+ * Returns true if any messages were evicted. */
781+ private fun evictOldMessages(): Boolean {
782+ var evicted = false
783+ while (messages.size > maxMessages) {
784+ val oldestKey = messages.keys.iterator().next()
785+ val removed = messages.remove(oldestKey)
786+ // Record the evicted message's id so a late MessagePartUpdated for it is dropped
787+ // instead of re-inserting a single-part bubble (see upsertPart). Also clear any
788+ // prior eviction record for this id from an older cycle (the message re-entered
789+ // via seed/MessageUpdated and has now been evicted again).
790+ removed?.info?.id?.takeIf { it.isNotEmpty() }?.let { id ->
791+ evictedMessageIds.add(id)
792+ }
793+ // Prune the evicted message's pivot bookkeeping so these sets don't grow
794+ // unbounded over a long-running session that never reconnects (they're only
795+ // otherwise cleared on reseed/seed).
796+ messageInfoUpdatedSincePivot.remove(oldestKey)
797+ removed?.parts?.keys?.forEach { streamedSincePivot.remove(it) }
798+ evicted = true
799+ }
800+ return evicted
801+ }
802+}
app/src/main/java/soy/iko/opencode/data/repo/SettingsStore.ktnew file+279
@@ -0,0 +1,279 @@
1+package soy.iko.opencode.data.repo
2+
3+import android.content.Context
4+import androidx.datastore.preferences.core.booleanPreferencesKey
5+import androidx.datastore.preferences.core.edit
6+import androidx.datastore.preferences.core.floatPreferencesKey
7+import androidx.datastore.preferences.core.intPreferencesKey
8+import androidx.datastore.preferences.core.stringPreferencesKey
9+import androidx.datastore.preferences.preferencesDataStore
10+import kotlinx.coroutines.flow.Flow
11+import kotlinx.coroutines.flow.map
12+
13+enum class ThemeMode { SYSTEM, LIGHT, DARK, AMOLED }
14+
15+private val Context.settingsDataStore by preferencesDataStore(name = "settings")
16+
17+/** App-preferences store: theme mode, dynamic color (Material You), input behavior. */
18+@Suppress("TooManyFunctions")
19+class SettingsStore(context: Context) {
20+
21+ private val appContext = context.applicationContext
22+ private val themeKey = stringPreferencesKey("theme_mode")
23+ private val dynamicColorKey = booleanPreferencesKey("dynamic_color")
24+ private val sendOnEnterKey = booleanPreferencesKey("send_on_enter")
25+ private val appLockKey = booleanPreferencesKey("app_lock")
26+ private val appLockReLockSecondsKey = intPreferencesKey("app_lock_relock_seconds")
27+ private val chatTextScaleKey = floatPreferencesKey("chat_text_scale")
28+ private val codeWrapKey = booleanPreferencesKey("code_wrap")
29+ private val sessionSortModeKey = stringPreferencesKey("session_sort_mode")
30+ private val sessionSortDescendingKey = booleanPreferencesKey("session_sort_descending")
31+ private val sessionShowArchivedKey = booleanPreferencesKey("session_show_archived")
32+ private val preferredModelIdKey = stringPreferencesKey("preferred_model_id")
33+ private val preferredAgentNameKey = stringPreferencesKey("preferred_agent_name")
34+ private val compactMessageSpacingKey = booleanPreferencesKey("compact_message_spacing")
35+ private val hapticsEnabledKey = booleanPreferencesKey("haptics_enabled")
36+ private val reducedMotionKey = booleanPreferencesKey("reduced_motion")
37+ private val languageOverrideKey = stringPreferencesKey("language_override")
38+ private val notifRunCompleteKey = booleanPreferencesKey("notif_run_complete")
39+ private val notifPermissionKey = booleanPreferencesKey("notif_permission")
40+ private val notifErrorKey = booleanPreferencesKey("notif_error")
41+ private val swipeLeftActionKey = stringPreferencesKey("swipe_left_action")
42+ private val swipeRightActionKey = stringPreferencesKey("swipe_right_action")
43+
44+ companion object {
45+ /** Bounds for the chat text-size multiplier so the UI can't be scaled into
46+ * unreadability or an unusable micro-font. 1.0 is the design default. */
47+ const val MIN_CHAT_TEXT_SCALE = 0.8f
48+ const val MAX_CHAT_TEXT_SCALE = 1.6f
49+ const val DEFAULT_CHAT_TEXT_SCALE = 1.0f
50+ /** 0 means re-lock the instant the app backgrounds. Other entries are the grace
51+ * period (seconds) during which a quick app-switch does NOT re-prompt for auth. */
52+ val APP_LOCK_RELOCK_OPTIONS_SECONDS: List<Int> = listOf(0, 30, 60, 300, 1800, 3600)
53+ const val DEFAULT_APP_LOCK_RELOCK_SECONDS = 0
54+ }
55+
56+ val themeMode: Flow<ThemeMode> = appContext.settingsDataStore.data.map { prefs ->
57+ runCatching { ThemeMode.valueOf(prefs[themeKey] ?: ThemeMode.SYSTEM.name) }
58+ .getOrDefault(ThemeMode.SYSTEM)
59+ }
60+
61+ // Default OFF so the app ships with its hand-built "Tokyo Night" brand palette rather than
62+ // the wallpaper-derived Material You scheme, which fully replaces the brand colours. Users
63+ // on Android 12+ can still opt into dynamic color from Settings.
64+ val dynamicColor: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
65+ prefs[dynamicColorKey] ?: false
66+ }
67+
68+ /** When true, the hardware Enter key sends the prompt; when false, Enter inserts a
69+ * newline and Ctrl+Enter sends. Soft-keyboard IME action always sends. Defaults to
70+ * true to match the prior hardcoded behavior. */
71+ val sendOnEnter: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
72+ prefs[sendOnEnterKey] ?: true
73+ }
74+
75+ /** When true, the app requires device biometric/credential authentication on launch
76+ * (and when returning from the background) before the UI is shown, protecting the
77+ * stored server credentials. Defaults to false so the app is usable out of the box. */
78+ val appLock: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
79+ prefs[appLockKey] ?: false
80+ }
81+
82+ /** Grace period (seconds) before re-locking after the app backgrounds. 0 = re-lock
83+ * immediately (the prior behavior). Lets a quick app-switch not re-prompt, which is the
84+ * standard reason users disable biometric locks in messaging/banking apps. */
85+ val appLockReLockSeconds: Flow<Int> = appContext.settingsDataStore.data.map { prefs ->
86+ prefs[appLockReLockSecondsKey] ?: DEFAULT_APP_LOCK_RELOCK_SECONDS
87+ }
88+
89+ /** Multiplier applied to chat message (markdown) and code text so users can size the
90+ * conversation for readability independently of the system font scale. Clamped to
91+ * [[MIN_CHAT_TEXT_SCALE], [MAX_CHAT_TEXT_SCALE]] on read so a corrupt/out-of-range
92+ * stored value can never produce an unreadable UI. Defaults to 1.0 (design size). */
93+ val chatTextScale: Flow<Float> = appContext.settingsDataStore.data.map { prefs ->
94+ (prefs[chatTextScaleKey] ?: DEFAULT_CHAT_TEXT_SCALE)
95+ .coerceIn(MIN_CHAT_TEXT_SCALE, MAX_CHAT_TEXT_SCALE)
96+ }
97+
98+ /** When true, fenced/indented code blocks soft-wrap long lines instead of scrolling
99+ * horizontally. Defaults to false (horizontal scroll) to preserve code formatting. */
100+ val codeWrap: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
101+ prefs[codeWrapKey] ?: false
102+ }
103+
104+ /** Persisted session-list sort mode so a chosen ordering survives process death (not just
105+ * rotation). Stored as the enum name; an unknown value falls back to RECENT. */
106+ val sessionSortMode: Flow<String> = appContext.settingsDataStore.data.map { prefs ->
107+ prefs[sessionSortModeKey] ?: "RECENT"
108+ }
109+
110+ /** Persisted session-list sort direction. Defaults to descending (newest first). */
111+ val sessionSortDescending: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
112+ prefs[sessionSortDescendingKey] ?: true
113+ }
114+
115+ /** Persisted "show archived sessions" toggle so the choice survives process death. */
116+ val sessionShowArchived: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
117+ prefs[sessionShowArchivedKey] ?: false
118+ }
119+
120+ /** Preferred default model id for new sessions. Empty means "use the server default".
121+ * A user who always picks a specific model doesn't have to pick it every session. */
122+ val preferredModelId: Flow<String> = appContext.settingsDataStore.data.map { prefs ->
123+ prefs[preferredModelIdKey] ?: ""
124+ }
125+
126+ /** Preferred default agent name for new sessions. Empty means "use the server default". */
127+ val preferredAgentName: Flow<String> = appContext.settingsDataStore.data.map { prefs ->
128+ prefs[preferredAgentNameKey] ?: ""
129+ }
130+
131+ /** When true, chat message bubbles use a tighter vertical spacing (8dp vs the default 16dp)
132+ * so power users can fit more conversation on screen. Defaults to false (the design default). */
133+ val compactMessageSpacing: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
134+ prefs[compactMessageSpacingKey] ?: false
135+ }
136+
137+ /** When true, haptic feedback is fired on taps and confirmations. Defaults to true (the
138+ * existing behavior). An in-app toggle lets users disable haptics without turning off
139+ * system-wide vibration. Read by a CompositionLocal that gates haptic calls. */
140+ val hapticsEnabled: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
141+ prefs[hapticsEnabledKey] ?: true
142+ }
143+
144+ /** When true, animations and transitions are limited (collapsed to instant/snap).
145+ * Defaults to false — the app already honors the system Developer Options animator
146+ * scale, but this gives users an in-app toggle without disabling motion OS-wide.
147+ * Read by a CompositionLocal that gates animation specs. */
148+ val reducedMotion: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
149+ prefs[reducedMotionKey] ?: false
150+ }
151+
152+ /** Override the app language (an ISO 639-1 code like "en" or "es", or empty for the
153+ * system default). Applied via AppCompat's per-app language API so the OS can
154+ * handle locale resolution without an app restart on Android 13+. */
155+ val languageOverride: Flow<String> = appContext.settingsDataStore.data.map { prefs ->
156+ prefs[languageOverrideKey] ?: ""
157+ }
158+
159+ /** Granular notification toggles. Each gates a notification channel's posting so a user
160+ * can mute specific notification types in-app (in addition to the OS channel controls).
161+ * Default true for all (matching the prior "always post" behavior). */
162+ val notifRunComplete: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
163+ prefs[notifRunCompleteKey] ?: true
164+ }
165+ val notifPermission: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
166+ prefs[notifPermissionKey] ?: true
167+ }
168+ val notifError: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
169+ prefs[notifErrorKey] ?: true
170+ }
171+
172+ /** Remappable swipe actions for the session list. Stored as the SwipeAction enum name;
173+ * an unknown value falls back to the default. Defaults: left=DELETE, right=ARCHIVE
174+ * (matching the prior hardcoded behavior). */
175+ val swipeLeftAction: Flow<String> = appContext.settingsDataStore.data.map { prefs ->
176+ prefs[swipeLeftActionKey] ?: SwipeAction.DELETE.name
177+ }
178+ val swipeRightAction: Flow<String> = appContext.settingsDataStore.data.map { prefs ->
179+ prefs[swipeRightActionKey] ?: SwipeAction.ARCHIVE.name
180+ }
181+
182+ suspend fun setThemeMode(mode: ThemeMode) {
183+ appContext.settingsDataStore.edit { it[themeKey] = mode.name }
184+ }
185+
186+ suspend fun setDynamicColor(enabled: Boolean) {
187+ appContext.settingsDataStore.edit { it[dynamicColorKey] = enabled }
188+ }
189+
190+ suspend fun setSendOnEnter(enabled: Boolean) {
191+ appContext.settingsDataStore.edit { it[sendOnEnterKey] = enabled }
192+ }
193+
194+ suspend fun setAppLock(enabled: Boolean) {
195+ appContext.settingsDataStore.edit { it[appLockKey] = enabled }
196+ }
197+
198+ suspend fun setAppLockReLockSeconds(seconds: Int) {
199+ appContext.settingsDataStore.edit {
200+ it[appLockReLockSecondsKey] = APP_LOCK_RELOCK_OPTIONS_SECONDS.minBy { k -> Math.abs(k - seconds) }
201+ }
202+ }
203+
204+ suspend fun setChatTextScale(scale: Float) {
205+ appContext.settingsDataStore.edit {
206+ it[chatTextScaleKey] = scale.coerceIn(MIN_CHAT_TEXT_SCALE, MAX_CHAT_TEXT_SCALE)
207+ }
208+ }
209+ suspend fun setCodeWrap(enabled: Boolean) {
210+ appContext.settingsDataStore.edit {
211+ it[codeWrapKey] = enabled
212+ }
213+ }
214+
215+ suspend fun setSessionSortMode(mode: String) {
216+ appContext.settingsDataStore.edit { it[sessionSortModeKey] = mode }
217+ }
218+
219+ suspend fun setSessionSortDescending(descending: Boolean) {
220+ appContext.settingsDataStore.edit { it[sessionSortDescendingKey] = descending }
221+ }
222+ suspend fun setSessionShowArchived(show: Boolean) {
223+ appContext.settingsDataStore.edit {
224+ it[sessionShowArchivedKey] = show
225+ }
226+ }
227+
228+ suspend fun setPreferredModelId(modelId: String) {
229+ appContext.settingsDataStore.edit {
230+ if (modelId.isEmpty()) it.remove(preferredModelIdKey) else it[preferredModelIdKey] = modelId
231+ }
232+ }
233+
234+ suspend fun setPreferredAgentName(agentName: String) {
235+ appContext.settingsDataStore.edit {
236+ if (agentName.isEmpty()) it.remove(preferredAgentNameKey) else it[preferredAgentNameKey] = agentName
237+ }
238+ }
239+
240+ suspend fun setCompactMessageSpacing(enabled: Boolean) {
241+ appContext.settingsDataStore.edit {
242+ it[compactMessageSpacingKey] = enabled
243+ }
244+ }
245+
246+ suspend fun setHapticsEnabled(enabled: Boolean) {
247+ appContext.settingsDataStore.edit { it[hapticsEnabledKey] = enabled }
248+ }
249+
250+ suspend fun setReducedMotion(enabled: Boolean) {
251+ appContext.settingsDataStore.edit { it[reducedMotionKey] = enabled }
252+ }
253+
254+ suspend fun setLanguageOverride(code: String) {
255+ appContext.settingsDataStore.edit {
256+ if (code.isEmpty()) it.remove(languageOverrideKey) else it[languageOverrideKey] = code
257+ }
258+ }
259+
260+ suspend fun setNotifRunComplete(enabled: Boolean) {
261+ appContext.settingsDataStore.edit { it[notifRunCompleteKey] = enabled }
262+ }
263+ suspend fun setNotifPermission(enabled: Boolean) {
264+ appContext.settingsDataStore.edit { it[notifPermissionKey] = enabled }
265+ }
266+ suspend fun setNotifError(enabled: Boolean) {
267+ appContext.settingsDataStore.edit { it[notifErrorKey] = enabled }
268+ }
269+
270+ suspend fun setSwipeLeftAction(action: SwipeAction) {
271+ appContext.settingsDataStore.edit { it[swipeLeftActionKey] = action.name }
272+ }
273+ suspend fun setSwipeRightAction(action: SwipeAction) {
274+ appContext.settingsDataStore.edit { it[swipeRightActionKey] = action.name }
275+ }
276+}
277+
278+/** Remappable swipe actions for the session list. NONE disables the swipe entirely. */
279+enum class SwipeAction { DELETE, ARCHIVE, MARK_READ, NONE }
app/src/main/java/soy/iko/opencode/di/AppContainer.ktnew file+1570
@@ -0,0 +1,1570 @@
1+package soy.iko.opencode.di
2+
3+import android.content.Context
4+import android.net.ConnectivityManager
5+import android.net.Network
6+import android.net.NetworkCapabilities
7+import android.net.NetworkRequest
8+import android.util.Log
9+import soy.iko.opencode.data.model.AssistantMessage
10+import soy.iko.opencode.data.model.BusEvent
11+import soy.iko.opencode.data.model.FilePromptPart
12+import soy.iko.opencode.data.model.MessagePartUpdated
13+import soy.iko.opencode.data.model.MessageUpdated
14+import soy.iko.opencode.data.model.Permission
15+import soy.iko.opencode.data.model.PermissionReplied
16+import soy.iko.opencode.data.model.PermissionResponse
17+import soy.iko.opencode.data.model.PermissionUpdated
18+import soy.iko.opencode.data.model.StepFinishPart
19+import soy.iko.opencode.data.model.ServerProfile
20+import soy.iko.opencode.data.model.SessionError
21+import soy.iko.opencode.data.model.SessionIdle
22+import soy.iko.opencode.data.model.ToolPart
23+import soy.iko.opencode.data.model.TODO_WRITE_TOOL
24+import soy.iko.opencode.data.model.parseTodos
25+import soy.iko.opencode.data.model.statusEnum
26+import soy.iko.opencode.data.model.TodoStatus
27+import soy.iko.opencode.data.model.inputElement
28+import soy.iko.opencode.data.repo.AttachmentDraftStore
29+import soy.iko.opencode.data.repo.BackupManager
30+import soy.iko.opencode.data.repo.DraftStore
31+import soy.iko.opencode.data.repo.ErrorKind
32+import soy.iko.opencode.data.repo.FileBrowserPrefs
33+import soy.iko.opencode.data.repo.MessageCacheStore
34+import soy.iko.opencode.data.repo.OutboxMessage
35+import soy.iko.opencode.data.repo.OutboxStore
36+import soy.iko.opencode.data.repo.RecentModelsStore
37+import soy.iko.opencode.data.repo.SearchHistoryStore
38+import soy.iko.opencode.data.repo.SessionPrefsStore
39+import soy.iko.opencode.data.repo.SessionRepository
40+import soy.iko.opencode.data.repo.ProfileStore
41+import soy.iko.opencode.data.repo.SettingsStore
42+import soy.iko.opencode.data.repo.classifyError
43+import soy.iko.opencode.data.repo.responseStatusCode
44+import soy.iko.opencode.data.network.EventStreamClient
45+import soy.iko.opencode.data.network.HttpClientFactory
46+import soy.iko.opencode.data.network.NetworkConfig
47+import soy.iko.opencode.data.network.OpencodeApiClient
48+import soy.iko.opencode.notification.NotificationChannels
49+import soy.iko.opencode.notification.RunForegroundService
50+import soy.iko.opencode.util.safeExceptionSummary
51+import soy.iko.opencode.notification.SessionNotifications
52+import soy.iko.opencode.R
53+import soy.iko.opencode.util.runCatchingCancellable
54+import kotlinx.coroutines.CoroutineScope
55+import kotlinx.coroutines.Dispatchers
56+import kotlinx.coroutines.ExperimentalCoroutinesApi
57+import kotlinx.coroutines.SupervisorJob
58+import kotlinx.coroutines.cancel
59+import kotlinx.coroutines.delay
60+import kotlinx.coroutines.flow.MutableSharedFlow
61+import kotlinx.coroutines.flow.MutableStateFlow
62+import kotlinx.coroutines.flow.SharedFlow
63+import kotlinx.coroutines.flow.StateFlow
64+import kotlinx.coroutines.flow.asSharedFlow
65+import kotlinx.coroutines.flow.asStateFlow
66+import kotlinx.coroutines.flow.collectLatest
67+import kotlinx.coroutines.flow.combine
68+import kotlinx.coroutines.flow.debounce
69+import kotlinx.coroutines.flow.distinctUntilChanged
70+import kotlinx.coroutines.flow.drop
71+import kotlinx.coroutines.flow.emptyFlow
72+import kotlinx.coroutines.flow.first
73+import kotlinx.coroutines.Job
74+import kotlinx.coroutines.flow.flatMapLatest
75+import kotlinx.coroutines.flow.map
76+import kotlinx.coroutines.flow.update
77+import kotlinx.coroutines.isActive
78+import kotlinx.coroutines.launch
79+import kotlinx.coroutines.sync.Mutex
80+import kotlinx.coroutines.sync.withLock
81+import kotlinx.coroutines.withTimeoutOrNull
82+import java.util.concurrent.atomic.AtomicBoolean
83+
84+/**
85+ * Result of probing a server URL to check connectivity and whether authentication
86+ * is required, without persisting a profile or opening a long-lived connection.
87+ */
88+sealed class ProbeResult {
89+ /** Server is reachable and accepts unauthenticated requests. */
90+ object Reachable : ProbeResult()
91+
92+ /** Server is reachable but rejected the probe with an authentication error. */
93+ object NeedsAuth : ProbeResult()
94+
95+ /** Server could not be reached (host unresolved, connection refused, timeout, etc.). */
96+ data class Unreachable(val error: String) : ProbeResult()
97+}
98+
99+/**
100+ * Hand-written service locator held by [soy.iko.opencode.OpencodeApp]. Owns the
101+ * process-wide singletons and the currently active [OpencodeConnection].
102+ */
103+open class AppContainer private constructor(
104+ private val appContext: Context?,
105+ private val skipInit: Boolean,
106+ @Suppress("unused") private val testMode: Boolean,
107+) {
108+ constructor(context: Context) : this(context.applicationContext, false, false)
109+ protected constructor() : this(null, true, true)
110+
111+ private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
112+ open val profileStore: ProfileStore by lazy { ProfileStore(appContext!!) }
113+ open val settingsStore: SettingsStore by lazy { SettingsStore(appContext!!) }
114+ open val draftStore: DraftStore by lazy { DraftStore(appContext!!, appScope) }
115+ open val attachmentDraftStore: AttachmentDraftStore by lazy { AttachmentDraftStore(appContext!!) }
116+ open val sessionPrefsStore: SessionPrefsStore by lazy { SessionPrefsStore(appContext!!) }
117+ open val messageCacheStore: MessageCacheStore by lazy { MessageCacheStore(appContext!!) }
118+ open val outboxStore: OutboxStore by lazy { OutboxStore(appContext!!) }
119+ open val searchHistoryStore: SearchHistoryStore by lazy { SearchHistoryStore(appContext!!) }
120+ open val recentModelsStore: RecentModelsStore by lazy { RecentModelsStore(appContext!!) }
121+ open val fileBrowserPrefs: FileBrowserPrefs by lazy { FileBrowserPrefs(appContext!!) }
122+ open val backupManager: BackupManager by lazy { BackupManager(profileStore, settingsStore, sessionPrefsStore) }
123+
124+ /** True while the outbox is actively flushing queued messages to the server, so the UI
125+ * can show a "sending queued messages…" indicator. */
126+ private val _outboxSending = MutableStateFlow(false)
127+ open val outboxSending: StateFlow<Boolean> = _outboxSending.asStateFlow()
128+
129+ /** Bumped to nudge an immediate outbox flush (after enqueue, or a manual "Send now"),
130+ * independent of connection/online changes. */
131+ private val _outboxFlushTrigger = MutableStateFlow(0)
132+ open fun flushOutbox() { _outboxFlushTrigger.update { it + 1 } }
133+
134+ private val _activeConnection = MutableStateFlow<OpencodeConnection?>(null)
135+ open val activeConnection: StateFlow<OpencodeConnection?> = _activeConnection.asStateFlow()
136+
137+ /** True while a background auto-reconnect to the most-recent server is in flight. */
138+ private val _reconnecting = MutableStateFlow(false)
139+ open val reconnecting: StateFlow<Boolean> = _reconnecting.asStateFlow()
140+
141+ /**
142+ * Set once a startup auto-reconnect succeeds. The server list collects this and
143+ * (once) navigates straight to the session list so a returning user skips it.
144+ * [consumeAutoConnect] guards against re-firing on screen re-entry.
145+ */
146+ private val _autoConnectDone = MutableStateFlow(false)
147+ open val autoConnectDone: StateFlow<Boolean> = _autoConnectDone.asStateFlow()
148+ private val autoConnectConsumed = AtomicBoolean(false)
149+ open fun consumeAutoConnect(): Boolean = autoConnectConsumed.compareAndSet(false, true)
150+
151+ /** Text shared into the app (ACTION_SEND), prefilled into a session draft. */
152+ private val _pendingShare = MutableStateFlow<String?>(null)
153+ open val pendingShare: StateFlow<String?> = _pendingShare.asStateFlow()
154+ open fun setPendingShare(text: String) { _pendingShare.value = text }
155+ open fun consumePendingShare(): String? {
156+ val current = _pendingShare.value ?: return null
157+ return if (_pendingShare.compareAndSet(current, null)) current else null
158+ }
159+
160+ /** Image/file Uris (as strings) shared into the app, staged as attachments in the next
161+ * opened session. Consumed once by the first ChatScreen to compose after the share. */
162+ private val _pendingSharedMedia = MutableStateFlow<List<String>>(emptyList())
163+ open val pendingSharedMedia: StateFlow<List<String>> = _pendingSharedMedia.asStateFlow()
164+ open fun setPendingSharedMedia(uris: List<String>) { _pendingSharedMedia.value = uris }
165+ open fun consumePendingSharedMedia(): List<String> {
166+ val current = _pendingSharedMedia.value
167+ if (current.isEmpty()) return emptyList()
168+ return if (_pendingSharedMedia.compareAndSet(current, emptyList())) current else emptyList()
169+ }
170+
171+ /**
172+ * Set from an external "New session" trigger (launcher shortcut / QS tile). The nav host
173+ * consumes it once connected, creating a fresh session and opening it. Uses a counter
174+ * (not a boolean) so re-tapping the shortcut while a prior request is still pending
175+ * increments the value and re-fires the LaunchedEffect — a boolean StateFlow wouldn't
176+ * emit when set to the same value, making the request unrecoverable by re-tapping.
177+ */
178+ private val _pendingNewSession = MutableStateFlow(0)
179+ open val pendingNewSession: StateFlow<Int> = _pendingNewSession.asStateFlow()
180+ open fun requestNewSession() { _pendingNewSession.update { it + 1 } }
181+ open fun consumePendingNewSession(): Boolean {
182+ while (true) {
183+ val current = _pendingNewSession.value
184+ if (current == 0) return false
185+ if (_pendingNewSession.compareAndSet(current, current - 1)) return true
186+ }
187+ }
188+
189+ /** A one-shot signal to navigate to the Diagnostics screen (e.g. from the crash-relaunch
190+ * prompt). Consumed by OpencodeApp's NavHost. */
191+ private val _pendingDiagnostics = MutableStateFlow(false)
192+ open val pendingDiagnostics: StateFlow<Boolean> = _pendingDiagnostics.asStateFlow()
193+ open fun requestDiagnostics() { _pendingDiagnostics.value = true }
194+ open fun consumePendingDiagnostics(): Boolean = _pendingDiagnostics.compareAndSet(true, false)
195+
196+ /**
197+ * A session id to open from an external trigger (a notification tap or a deep link),
198+ * paired with the originating server's profile id (if known) so a tap after the user
199+ * has switched servers routes back to the server that ran the session. The nav host
200+ * consumes it once a connection is active (switching to [profileId] first if needed).
201+ */
202+ data class PendingOpenSession(val sessionId: String, val profileId: String?)
203+
204+ private val _pendingOpenSession = MutableStateFlow<PendingOpenSession?>(null)
205+ open val pendingOpenSession: StateFlow<PendingOpenSession?> = _pendingOpenSession.asStateFlow()
206+ open fun requestOpenSession(id: String, profileId: String? = null) {
207+ _pendingOpenSession.value = PendingOpenSession(id, profileId)
208+ }
209+ open fun consumePendingOpenSession(): PendingOpenSession? {
210+ val current = _pendingOpenSession.value ?: return null
211+ return if (_pendingOpenSession.compareAndSet(current, null)) current else null
212+ }
213+
214+ /**
215+ * A file path requested to be opened (via deep link `opencode://file/{path}` or future
216+ * entry points), consumed by the NavHost to navigate to the file viewer. Mirrors the
217+ * session open-signal pattern: a StateFlow the NavHost observes, drained atomically.
218+ */
219+ private val _pendingOpenFile = MutableStateFlow<String?>(null)
220+ open val pendingOpenFile: StateFlow<String?> = _pendingOpenFile.asStateFlow()
221+ open fun requestOpenFile(path: String) { _pendingOpenFile.value = path }
222+ open fun consumePendingOpenFile(): String? {
223+ val current = _pendingOpenFile.value ?: return null
224+ return if (_pendingOpenFile.compareAndSet(current, null)) current else null
225+ }
226+
227+ /**
228+ * The session the user is currently viewing (or null when not in a chat). Drives the
229+ * unread tracker: messages arriving for any *other* session mark it unread, and
230+ * opening a session clears its unread state.
231+ */
232+ private val _currentSession = MutableStateFlow<String?>(null)
233+ open val currentSession: StateFlow<String?> = _currentSession.asStateFlow()
234+
235+ /** Session ids that received activity while not being viewed, mapped to the count
236+ * of unread message events. A count (not just a presence set) lets the session
237+ * list badge show "3 unread" instead of a bare dot, so the user can tell whether
238+ * one reply or a whole burst arrived. */
239+ private val _unread = MutableStateFlow<Map<String, Int>>(emptyMap())
240+ open val unread: StateFlow<Map<String, Int>> = _unread.asStateFlow()
241+
242+ /** Session ids the user has muted. A muted session doesn't badge unread (the count is
243+ * still tracked internally so unmuting can restore it, but the badge and completion
244+ * notification are suppressed). Persisted via SessionPrefsStore so the choice survives
245+ * process death. */
246+ private val _mutedSessions = MutableStateFlow<Set<String>>(emptySet())
247+ open val mutedSessions: StateFlow<Set<String>> = _mutedSessions.asStateFlow()
248+
249+ /** True when any assistant run is actively streaming across all sessions. Drives the
250+ * disconnect confirmation on the session list (disconnecting mid-run kills it). */
251+ private val _anyRunActive = MutableStateFlow(false)
252+ open val anyRunActive: StateFlow<Boolean> = _anyRunActive.asStateFlow()
253+
254+ /** Session ids currently streaming an assistant run, surfaced so the session list can
255+ * badge the row whose run is active. Mirrors [activeRuns] as an immutable snapshot so
256+ * Compose can skip recomposition when the set hasn't changed. */
257+ private val _runningSessionIds = MutableStateFlow<Set<String>>(emptySet())
258+ open val runningSessionIds: StateFlow<Set<String>> = _runningSessionIds.asStateFlow()
259+
260+ /** Latest task-plan progress text for the active run (e.g. "Step 2 of 5"), surfaced in
261+ * the foreground-service notification so the user can see what the agent is working on
262+ * without opening the app. Null when no plan is available or no run is active. */
263+ private val _runProgressText = MutableStateFlow<String?>(null)
264+ open val runProgressText: StateFlow<String?> = _runProgressText.asStateFlow()
265+
266+ /** Whether the device currently has network connectivity. Distinct from the SSE
267+ * connection state so the UI can tell "you're offline" (device) from "server
268+ * unreachable" (credentials/host). Updated by the [networkCallback]. */
269+ private val _isOnline = MutableStateFlow(true)
270+ open val isOnline: StateFlow<Boolean> = _isOnline.asStateFlow()
271+
272+ /** Whether a foreground Activity is currently showing. Distinct from [currentSession],
273+ * which stays set while the app is merely backgrounded (the chat screen isn't disposed
274+ * when the user locks their phone). Permission/completion notifications need the real
275+ * foreground signal so they still fire for a session the user "has open" but has walked
276+ * away from — the core of the run-in-the-background use case. Set from MainActivity's
277+ * onStart/onStop. */
278+ private val _isForeground = MutableStateFlow(false)
279+ open val isForeground: StateFlow<Boolean> = _isForeground.asStateFlow()
280+ open fun setForeground(foreground: Boolean) { _isForeground.value = foreground }
281+
282+ /** True when the user is actively looking at [sessionId] (app foregrounded AND that
283+ * session is the one on screen), so an in-app affordance (the permission dialog) is
284+ * handling it and a notification would be redundant. */
285+ private fun isActivelyViewing(sessionId: String): Boolean =
286+ _isForeground.value && sessionId == _currentSession.value
287+
288+ open fun setCurrentSession(id: String?) {
289+ _currentSession.value = id
290+ if (id != null) {
291+ _unread.update { it - id }
292+ unreadMessageIds.remove(id)
293+ appContext?.let {
294+ SessionNotifications.cancel(it, id)
295+ SessionNotifications.cancelPermission(it, id)
296+ SessionNotifications.cancelError(it, id)
297+ }
298+ }
299+ }
300+
301+ /** Clear unread badge and dedup set for a deleted session so its stale count doesn't
302+ * linger in the session list badge or leak memory until the next connect/disconnect. */
303+ open fun clearUnread(id: String) {
304+ _unread.update { it - id }
305+ unreadMessageIds.remove(id)
306+ }
307+
308+ /** Mark a session as read (clear its unread badge) — the "Mark read" notification action.
309+ * Updates the launcher-icon badge directly (not just the in-memory [StateFlow]) so a
310+ * "Mark read" tap while the app is backgrounded — when [SessionListScreen]'s
311+ * `LaunchedEffect(totalUnread)` collector isn't running — still decrements the badge.
312+ * Without this, the notification dismisses but the launcher badge shows a stale count
313+ * until the app is next foregrounded. */
314+ open fun markSessionRead(id: String) {
315+ clearUnread(id)
316+ appContext?.let { ctx ->
317+ SessionNotifications.updateUnreadBadge(ctx, _unread.value.values.sum())
318+ }
319+ }
320+
321+ /** Mark a session as unread with a count of 1 (or restore a prior count). Used by the
322+ * session list's "Mark as unread" overflow action so a user can re-flag a conversation
323+ * they want to revisit. No-op when the session is the one currently being viewed — a
324+ * viewed session clears its own unread badge via [setCurrentSession], so marking it
325+ * unread would race and immediately clear. Muted sessions are still marked (the count is
326+ * tracked so unmuting restores it) but the list UI hides the badge while muted. */
327+ open fun markSessionUnread(id: String, count: Int = 1) {
328+ if (id == _currentSession.value) return
329+ _unread.update { it + (id to count) }
330+ }
331+
332+ /** Toggle a session's mute state. A muted session suppresses the unread badge and the
333+ * completion notification so a noisy conversation doesn't interrupt. Persisted
334+ * via SessionPrefsStore so it survives process death. */
335+ fun toggleSessionMute(id: String) {
336+ // Use atomic update so a concurrent prefs reload (_mutedSessions.value = ids from
337+ // sessionPrefsStore.muted.collect) can't clobber this toggle mid read-modify-write.
338+ var nowMuted = false
339+ _mutedSessions.update { current ->
340+ nowMuted = id !in current
341+ if (nowMuted) current + id else current - id
342+ }
343+ appContext?.let {
344+ appScope.launch {
345+ runCatchingCancellable { sessionPrefsStore.setMuted(id, nowMuted) }
346+ }
347+ }
348+ }
349+
350+ /** True when [id] is muted (unread badge and completion notification suppressed). */
351+ fun isSessionMuted(id: String): Boolean = id in _mutedSessions.value
352+
353+ /** Clear every session's unread badge at once — the session list's "Mark all read" action. */
354+ open fun clearAllUnread() {
355+ _unread.update { emptyMap() }
356+ }
357+
358+ /** Restore a session's unread badge after a failed server switch reconnects.
359+ * Preserves the prior [count] so a session badged with "5 unread" before the
360+ * switch attempt still shows 5 (not 1) after the restore. No-op for the session
361+ * the user is currently viewing, since a viewed session is never badged. */
362+ open fun restoreUnread(id: String, count: Int) {
363+ if (id != _currentSession.value) {
364+ _unread.update { it + (id to count) }
365+ }
366+ }
367+
368+ /** Resolve a localized string — view models reach resources through the container. */
369+ open fun string(id: Int, vararg formatArgs: Any): String {
370+ if (appContext == null) return ""
371+ return if (formatArgs.isEmpty()) appContext.getString(id) else appContext.getString(id, *formatArgs)
372+ }
373+
374+ /**
375+ * Convert a throwable into a user-facing message. Classifies the error by concrete
376+ * type (network, timeout, HTTP status) rather than string-matching class names, so
377+ * the message reflects what actually went wrong without leaking internal URLs/state.
378+ */
379+ open fun friendlyError(t: Throwable): String =
380+ friendlyErrorFor(t, activeConnection.value?.profile?.baseUrl.orEmpty())
381+
382+ /**
383+ * Same classification as [friendlyError] but accepts an explicit base URL, so callers
384+ * that aren't operating on the active connection (e.g. [probeServer]) can still
385+ * produce a user-facing message with the right server address.
386+ */
387+ open fun friendlyErrorFor(t: Throwable, baseUrl: String): String =
388+ when (classifyError(t)) {
389+ ErrorKind.NOT_REACHABLE, ErrorKind.NETWORK ->
390+ string(R.string.error_not_reachable, baseUrl)
391+ ErrorKind.TIMEOUT -> string(R.string.error_timeout)
392+ ErrorKind.SERVER -> string(R.string.error_server)
393+ // Show only the HTTP status (e.g. 401, 404) — never the request URL,
394+ // which a ClientRequestException carries in its message and which we
395+ // promised not to leak (it can include auth or internal paths).
396+ ErrorKind.CLIENT -> {
397+ val code = responseStatusCode(t)
398+ when (code) {
399+ // Auth failures reuse the connection banner text: the request itself is
400+ // fine, the credentials aren't, so the fix is re-editing the profile —
401+ // not "request failed (401)" which implies an opaque program error.
402+ 401, 403 -> string(R.string.connection_failed)
403+ 404 -> string(R.string.error_not_found)
404+ else -> code?.let { string(R.string.error_client_status, it) }
405+ ?: string(R.string.error_generic)
406+ }
407+ }
408+ ErrorKind.UNKNOWN -> string(R.string.error_generic)
409+ }
410+
411+ /**
412+ * Drive the [RunForegroundService] from the process-lived [appScope] rather than from the
413+ * Activity's composition. Previously the start/stop was wired to a
414+ * `collectAsStateWithLifecycle` collector in the root composable, which pauses while the
415+ * Activity is STOPPED — so a run *started while backgrounded* (e.g. via the notification
416+ * inline-reply) never acquired foreground priority and its long-lived SSE stream could be
417+ * killed by Doze, and a run that *ended* while backgrounded left the "working…"
418+ * notification lingering until the app was reopened. Collecting here reacts to
419+ * [anyRunActive] regardless of Activity state.
420+ */
421+ private fun observeRunForegroundService() {
422+ val ctx = appContext ?: return
423+ appScope.launch {
424+ // Debounce so a run that starts then fails/idles almost immediately doesn't
425+ // dispatch startForegroundService() then stopService() back-to-back — if the stop
426+ // wins that race before the service calls startForeground(), Android raises the
427+ // "did not then call startForeground()" crash ~5s later. Collapsing the window
428+ // emits only the settled state; distinctUntilChanged drops a no-op re-emit when a
429+ // brief true→false→true flurry settles back to the value already applied.
430+ anyRunActive
431+ .debounce(NetworkConfig.runForegroundDebounceMs)
432+ .distinctUntilChanged()
433+ .collect { active ->
434+ if (active) {
435+ // When exactly one run is active, resolve its title (and id, for the
436+ // Stop action) so the notification identifies which session is running
437+ // and can be aborted from the notification. Multiple concurrent runs
438+ // fall back to the generic title (naming one would be misleading).
439+ val activeId = synchronized(activeRuns) {
440+ activeRuns.toList().singleOrNull()
441+ }
442+ val title = activeId?.let { sid ->
443+ runCatchingCancellable {
444+ activeConnection.value?.repository?.listSessions()
445+ ?.firstOrNull { it.id == sid }?.displayTitle
446+ }.getOrNull()
447+ }
448+ val profileId = activeConnection.value?.profile?.id
449+ RunForegroundService.start(ctx, title, activeId, profileId, _runProgressText.value)
450+ } else {
451+ RunForegroundService.stop(ctx)
452+ }
453+ }
454+ }
455+ // Separate collector: update the FGS notification's progress text ("Step 2 of 5")
456+ // as the agent works through its task plan. Re-sends the service intent with the
457+ // updated progress, which the service uses to rebuild the notification.
458+ appScope.launch {
459+ _runProgressText
460+ .drop(1) // skip initial value — the anyRunActive collector handles the first start
461+ .distinctUntilChanged()
462+ .collect { progress ->
463+ if (_anyRunActive.value) {
464+ val activeId = synchronized(activeRuns) { activeRuns.toList().singleOrNull() }
465+ val title = activeId?.let { sid ->
466+ runCatchingCancellable {
467+ activeConnection.value?.repository?.listSessions()
468+ ?.firstOrNull { it.id == sid }?.displayTitle
469+ }.getOrNull()
470+ }
471+ val profileId = activeConnection.value?.profile?.id
472+ RunForegroundService.start(ctx, title, activeId, profileId, progress)
473+ }
474+ }
475+ }
476+ }
477+
478+ /**
479+ * Post a system notification when the SSE stream hits a non-retryable failure
480+ * ([Failed]/[AuthFailed]) while the app is backgrounded during an active run, so a
481+ * backgrounded user is signaled that their run is stranded (the in-app banner isn't
482+ * visible). The notification is cancelled when the stream reconnects. This closes the
483+ * gap where a user who kicks off a run and backgrounds the app returns hours later to
484+ * find nothing happened and no signal why — the stream parked on a 4xx mid-run and the
485+ * in-app banner was never seen.
486+ */
487+ private fun observeBackgroundedConnectionFailures() {
488+ val ctx = appContext ?: return
489+ appScope.launch {
490+ activeConnection.collectLatest { conn ->
491+ if (conn == null) return@collectLatest
492+ conn.events.state.collect { state ->
493+ when (state) {
494+ EventStreamClient.ConnectionState.Failed,
495+ EventStreamClient.ConnectionState.AuthFailed -> {
496+ // Only notify when the app is backgrounded AND a run was recently
497+ // active (or still is) — a foregrounded user sees the banner, and
498+ // a failure with no run is less actionable.
499+ if (!_isForeground.value && _anyRunActive.value) {
500+ runCatchingCancellable {
501+ SessionNotifications.postConnectionLost(
502+ ctx, conn.profile.displayLabel, conn.profile.id,
503+ )
504+ }.onFailure {
505+ Log.w("AppContainer", "connection-lost notification failed: ${safeExceptionSummary(it)}")
506+ }
507+ }
508+ }
509+ EventStreamClient.ConnectionState.Connected -> {
510+ runCatchingCancellable { SessionNotifications.cancelConnectionLost(ctx) }
511+ }
512+ else -> {}
513+ }
514+ }
515+ }
516+ }
517+ }
518+
519+ /**
520+ * Reconcile active-run tracking on an SSE *reconnect*. A run that completes during a stream
521+ * outage never delivers its `SessionIdle`, so its id would stay in [activeRuns] and pin
522+ * [anyRunActive] (and the foreground service + "working…" notification) on indefinitely. On a
523+ * reconnect we clear the tracking and let live streaming events re-populate it: a genuinely
524+ * in-progress run re-adds itself within moments via [isLiveRunActivity], while a run that
525+ * finished during the outage produces no further live events (its completion arrives only via
526+ * the REST re-seed), so it correctly stays cleared. The first connect of a connection is not a
527+ * reconnect; `connect()` already resets [activeRuns], so only subsequent Connected transitions
528+ * trigger the sweep.
529+ */
530+ private fun observeRunReconcileOnReconnect() {
531+ appScope.launch {
532+ activeConnection.collectLatest { conn ->
533+ if (conn == null) return@collectLatest
534+ var hasConnectedBefore = false
535+ conn.events.state.collect { state ->
536+ if (state == EventStreamClient.ConnectionState.Connected) {
537+ if (hasConnectedBefore) {
538+ synchronized(activeRuns) {
539+ activeRuns.clear()
540+ publishRunState()
541+ }
542+ }
543+ hasConnectedBefore = true
544+ }
545+ }
546+ }
547+ }
548+ }
549+
550+ /**
551+ * Flush the offline outbox whenever there's an active connection and the device is
552+ * online (and on any manual [flushOutbox] nudge). Queued messages are only sent to the
553+ * server they were composed for (matched by profile id), so a reconnect to a different
554+ * server never misfires them against a session id that doesn't exist there. Uses a plain
555+ * `collect` (not collectLatest) so an in-flight send is never cancelled mid-flight and
556+ * send order is preserved; each message carries a stable idempotency key (its persistent
557+ * outbox id) so even a re-send across flushes is deduplicated server-side.
558+ */
559+ private fun observeOutbox() {
560+ appScope.launch {
561+ combine(activeConnection, isOnline, _outboxFlushTrigger) { conn, online, _ -> conn to online }
562+ .collect { (conn, online) ->
563+ if (conn == null || !online) return@collect
564+ flushOutboxNow(conn)
565+ }
566+ }
567+ }
568+
569+ private suspend fun flushOutboxNow(conn: OpencodeConnection) {
570+ val pending = outboxStore.messages.value
571+ .filter { it.profileId == conn.profile.id }
572+ .sortedBy { it.createdAt }
573+ if (pending.isEmpty()) return
574+ _outboxSending.value = true
575+ try {
576+ for (msg in pending) {
577+ val result = runCatchingCancellable {
578+ conn.repository.sendPrompt(
579+ msg.sessionId,
580+ msg.text,
581+ attachments = msg.attachments.map {
582+ FilePromptPart(mime = it.mime, url = it.url, filename = it.filename)
583+ },
584+ model = msg.model,
585+ agent = msg.agent,
586+ // Reuse the persistent outbox id as a stable idempotency key so a
587+ // later flush of a message whose earlier POST reached the server (but
588+ // whose response was lost) is deduplicated server-side instead of
589+ // starting a duplicate agent run.
590+ idempotencyKey = msg.id,
591+ )
592+ }.onFailure {
593+ Log.w("AppContainer", "Outbox flush failed for ${msg.sessionId}: ${safeExceptionSummary(it)}")
594+ }
595+ if (result.isSuccess) {
596+ outboxStore.remove(msg.id)
597+ continue
598+ }
599+ // A permanently-undeliverable message (a non-408/429 4xx — e.g. the target
600+ // session was deleted server-side, yielding a 404) must be dropped, not left at
601+ // the head of the queue. The loop stops at the first failure to preserve send
602+ // order, so a poison message would otherwise block every later queued message
603+ // for this profile forever. Transient failures (offline again, 5xx, 408/429)
604+ // keep the queue intact and retry on the next trigger.
605+ // 401/403 (bad/expired credentials) are NOT permanent: the message itself is
606+ // deliverable once the user fixes the profile. Dropping it would silently lose
607+ // the user's composed offline prompt on a recoverable auth failure. They stay
608+ // in the queue and retry on the next flush after auth is restored.
609+ val status = result.exceptionOrNull()?.let { responseStatusCode(it) }
610+ // Permanent = a non-408/429/401/403 4xx, matching withRetry's non-retryable rule
611+ // except for auth failures, which are recoverable by re-editing the profile.
612+ val permanent = when (status) {
613+ null, 408, 429, 401, 403 -> false
614+ in 400..499 -> true
615+ else -> false
616+ }
617+ if (permanent) {
618+ Log.w(
619+ "AppContainer",
620+ "Dropping undeliverable outbox message ${msg.id} for ${msg.sessionId} (HTTP $status): ${string(R.string.outbox_dropped_text)}",
621+ )
622+ outboxStore.remove(msg.id)
623+ // Surface the dropped reply on the ERROR channel with a distinct title
624+ // ("Message not delivered") so the user understands a queued reply
625+ // couldn't be delivered — not that a run failed (which postError
626+ // implies). Tap routes back to the originating profile's session.
627+ val ctx = appContext
628+ if (ctx != null) {
629+ val title = resolveSessionTitle(msg.sessionId, conn.repository)
630+ runCatchingCancellable {
631+ SessionNotifications.postOutboxDropped(
632+ ctx, msg.sessionId,
633+ title,
634+ conn.profile.id,
635+ )
636+ }.onFailure {
637+ Log.w("AppContainer", "outbox drop notification failed: ${safeExceptionSummary(it)}")
638+ }
639+ }
640+ } else {
641+ break
642+ }
643+ }
644+ } finally {
645+ _outboxSending.value = false
646+ }
647+ }
648+
649+ /** Release resources held for the process lifetime (network callback, app scope). */
650+ open fun shutdown() {
651+ // Cancel the app scope first so any coroutine holding the connection mutex
652+ // (e.g. a connect() suspended on profileStore) is cancelled and releases the
653+ // mutex. Otherwise the runBlocking below would deadlock waiting for a coroutine
654+ // that can't be cancelled until after the runBlocking completes.
655+ appScope.cancel()
656+ // Capture the active connection BEFORE the bounded close attempt so a timeout
657+ // can still close it outside the mutex. Without this, the timeout path would
658+ // null the StateFlow reference without closing the connection — leaking the SSE
659+ // socket, OkHttp pool, and the connection's coroutine scope.
660+ val stuck = _activeConnection.value
661+ var closed = false
662+ // Bounded runBlocking so a stuck mutex or a slow close() can't ANR the app.
663+ // 2 seconds is generous: appScope.cancel() already triggered cancellation of
664+ // any coroutine holding the mutex; we're just waiting for it to unwind.
665+ kotlinx.coroutines.runBlocking {
666+ withTimeoutOrNull(2_000) {
667+ connectionMutex.withLock {
668+ // runCatchingCancellable (not plain runCatching) per the repo convention:
669+ // close() is a suspend function, and withTimeoutOrNull above can cancel
670+ // this block mid-close — plain runCatching would swallow the
671+ // CancellationException, leaving the connection half-closed and falsely
672+ // reporting success via `closed = true`.
673+ runCatchingCancellable { _activeConnection.value?.close() }
674+ _activeConnection.value = null
675+ closed = true
676+ }
677+ }
678+ // If the timeout fired, force-clear the StateFlow so observers see no
679+ // connection. The stuck connection (if any) is closed below, outside the
680+ // mutex, so a slow close() can't ANR — its scope is cancelled and the JVM
681+ // exit reaps any remaining sockets.
682+ _activeConnection.value = null
683+ }
684+ if (!closed && stuck != null) {
685+ // The timeout path fired (the locked close didn't complete). Cancel the
686+ // stuck connection's scope directly. close() suspends (scopeJob.join), so
687+ // wrap in a bounded runBlocking that can't ANR — at this point the app is
688+ // shutting down, so reaping is best-effort.
689+ // runCatchingCancellable: withTimeoutOrNull can cancel stuck.close() mid-flight,
690+ // and plain runCatching would swallow the CancellationException — same invariant
691+ // as the locked close above.
692+ runCatchingCancellable {
693+ kotlinx.coroutines.runBlocking {
694+ kotlinx.coroutines.withTimeoutOrNull(1_000) { stuck.close() }
695+ }
696+ }
697+ }
698+ val cm = appContext?.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
699+ networkCallback?.let { runCatching { cm?.unregisterNetworkCallback(it) } }
700+ if (!skipInit) soy.iko.opencode.data.repo.CrashLogger.get(appContext!!).shutdown()
701+ if (!skipInit) draftStore.shutdown()
702+ }
703+
704+ /**
705+ * Watch the SSE bus for new message activity and badge any session that isn't
706+ * currently open. This powers the unread dot on the session list so the user can
707+ * tell which conversations got a reply while they were elsewhere.
708+ *
709+ * Uses [flatMapLatest] so a server switch (which replaces the active connection)
710+ * re-subscribes to the new event stream and drops the old one.
711+ */
712+ @OptIn(ExperimentalCoroutinesApi::class)
713+ private fun observeMessageActivity() {
714+ appScope.launch {
715+ // Retry the observer if it fails for any reason (transient flow exception,
716+ // cancellation due to scope issues). Without this, a single failure would
717+ // permanently disable unread tracking and completion notifications.
718+ while (isActive) {
719+ runCatchingCancellable {
720+ activeConnection
721+ .flatMapLatest { conn ->
722+ // Capture the originating connection so notification coroutines
723+ // resolve titles and route permission responses against THIS
724+ // server, not whichever happens to be active when they resume.
725+ // A server switch during a listSessions() round-trip would
726+ // otherwise resolve the title (or POST the permission reply)
727+ // against the new server, where the session/permission doesn't
728+ // exist — showing an opaque id or silently losing the reply.
729+ conn?.events?.events?.map { conn to it } ?: emptyFlow()
730+ }
731+ .collect { (conn, event) ->
732+ // A permission request needs the user's approval. Post a heads-up
733+ // notification (with Allow/Reject actions) unless the user is actively
734+ // viewing this session, where the in-app permission dialog handles it.
735+ if (event is PermissionUpdated) {
736+ val psid = event.properties.sessionID.takeIf { it.isNotBlank() } ?: return@collect
737+ if (!isActivelyViewing(psid)) appScope.launch { notifyPermission(event.properties, conn) }
738+ return@collect
739+ }
740+ // The permission was answered (in-app, from the notification, or
741+ // auto-resolved): clear its heads-up notification.
742+ if (event is PermissionReplied) {
743+ val psid = event.properties.sessionID?.takeIf { it.isNotBlank() } ?: return@collect
744+ appContext?.let { SessionNotifications.cancelPermission(it, psid) }
745+ return@collect
746+ }
747+ // A run failed. Clear its active-run tracking (an error isn't always
748+ // followed by a SessionIdle) and notify unless it's being viewed.
749+ // The notification is NOT gated on wasRunning: activeRuns is populated
750+ // only by SSE streaming events (MessagePartUpdated/MessageUpdated), so a
751+ // run that errors before any streaming started (e.g. the model is
752+ // unavailable, the server fails to create the assistant message) never
753+ // entered the set and wasRunning would be false — silently dropping the
754+ // error notification for a backgrounded user. SessionError itself is the
755+ // server's signal that a run failed, so notify on any error for a session
756+ // the user isn't actively viewing.
757+ if (event is SessionError) {
758+ val esid = event.properties.sessionID ?: return@collect
759+ synchronized(activeRuns) {
760+ if (activeRuns.remove(esid)) publishRunState()
761+ }
762+ if (!isActivelyViewing(esid)) {
763+ appScope.launch { notifySessionError(esid, conn.repository, conn.profile.id) }
764+ }
765+ return@collect
766+ }
767+ // SessionIdle is not "message activity" (don't badge as unread)
768+ // but signals a run finished — fire a completion notification if the
769+ // session isn't currently being viewed and was actively streaming.
770+ if (event is SessionIdle) {
771+ val idleSid = event.properties.sessionID ?: return@collect
772+ // Clear the run state unconditionally — even for the session on
773+ // screen. Gating the removal on `idleSid != currentSession` (as
774+ // this once did) leaks the id in activeRuns and pins anyRunActive
775+ // true forever whenever a run finishes while being viewed. Only
776+ // the completion *notification* is suppressed for the viewed one.
777+ val wasRunning = synchronized(activeRuns) {
778+ activeRuns.remove(idleSid).also { removed ->
779+ if (removed) publishRunState()
780+ }
781+ }
782+ // Clear the progress text when the run finishes.
783+ _runProgressText.value = null
784+ if (wasRunning && !isActivelyViewing(idleSid)) {
785+ // Fire this off the collector's coroutine: notifySessionCompleted
786+ // does a network listSessions() to resolve the title, and blocking
787+ // the shared-event collector here would stall the SharedFlow,
788+ // overflowing its DROP_OLDEST buffer and silently dropping live
789+ // parts/updates for every other subscriber (e.g. the message reducer).
790+ appScope.launch { notifySessionCompleted(idleSid, conn.repository, conn.profile.id) }
791+ }
792+ return@collect
793+ }
794+ // Track the agent's task-plan progress (from todowrite tool calls) so the
795+ // foreground-service notification can show "Step 2 of 5" instead of just a
796+ // chronometer. The todowrite tool sends the full plan on every call, so the
797+ // latest call's input is a complete snapshot.
798+ if (event is MessagePartUpdated) {
799+ val part = event.properties.part
800+ if (part is ToolPart && part.tool.equals(TODO_WRITE_TOOL, ignoreCase = true)) {
801+ val todos = parseTodos(part.state.inputElement())
802+ if (todos.isNotEmpty()) {
803+ val completed = todos.count { it.statusEnum() == TodoStatus.COMPLETED }
804+ _runProgressText.value = appContext?.getString(
805+ soy.iko.opencode.R.string.notif_running_progress,
806+ completed + 1,
807+ todos.size,
808+ )
809+ }
810+ }
811+ }
812+ val sid = sessionOf(event) ?: return@collect
813+ // Re-read currentSession here, right before mutating the badge state:
814+ // the user may have opened this session between when this event was
815+ // enqueued and now (setCurrentSession clears its badge and dedup set).
816+ // A stale earlier check would race a badge — and a dedup entry — back
817+ // in for the session that's actually on screen.
818+ if (sid != _currentSession.value) {
819+ // Track the message id in the dedup set for *every* session (muted or
820+ // not), so unmuting can't re-count messages that arrived while muted.
821+ // The visible badge increment below is gated on !muted so a muted
822+ // conversation doesn't interrupt, but the dedup tracking must not be
823+ // — otherwise the first event after unmute for an already-seen message
824+ // id would pass the counted.add() check (empty set) and re-increment.
825+ val messageId = messageIdOfEvent(event)
826+ val counted = unreadMessageIds.getOrPut(sid) {
827+ java.util.Collections.synchronizedSet(mutableSetOf())
828+ }
829+ val isNewMessage = messageId == null || counted.add(messageId)
830+ if (isNewMessage && sid !in _mutedSessions.value) {
831+ // Skip the unread badge for muted sessions — the count is still
832+ // tracked in unreadMessageIds (so unmuting can't re-count the
833+ // same messages), but the visible badge is suppressed so a muted
834+ // conversation doesn't interrupt. The completion notification is
835+ // also gated on isSessionMuted (see notifySessionCompleted).
836+ _unread.update { current ->
837+ // Guard again inside the atomic update lambda so a retry
838+ // (or an open that landed just now) can't reintroduce the
839+ // badge for the session on screen.
840+ if (sid == _currentSession.value) current
841+ else current + (sid to (current[sid] ?: 0) + 1)
842+ }
843+ }
844+ }
845+ // Track sessions actively streaming so we know which idle events
846+ // represent a finished run worth notifying about.
847+ if (isLiveRunActivity(event)) {
848+ // add() returns true only when the id is genuinely new. Only republish
849+ // the run-state snapshot in that case: a continuously-streaming session
850+ // re-adds its id on every token (hundreds/sec), and republishing each
851+ // time allocates a fresh Set via toSet() + writes two StateFlows under
852+ // the monitor — pure overhead since the set membership didn't change.
853+ // The access-order refresh from LinkedHashSet.add still happens (keeping
854+ // the session from being evicted by a reconnect sweep); we just skip the
855+ // O(set size) snapshot + StateFlow writes when nothing changed.
856+ synchronized(activeRuns) {
857+ if (activeRuns.add(sid)) {
858+ publishRunState()
859+ }
860+ }
861+ }
862+ } }.onFailure { Log.w("AppContainer", "Message activity observer failed, will retry: ${safeExceptionSummary(it)}") }
863+ if (!isActive) break
864+ delay(NetworkConfig.observerRetryDelayMs)
865+ }
866+ }
867+ }
868+
869+ /** True if [event] is live streaming activity worth tracking as an active run. A
870+ * trailing message.updated that lands *after* the run finished (final cost/token totals,
871+ * carrying a completion time) is NOT live streaming: with no further SessionIdle to
872+ * follow it, re-adding the session would pin anyRunActive true indefinitely. */
873+ private fun isLiveRunActivity(event: BusEvent): Boolean {
874+ if (event !is MessagePartUpdated && event !is MessageUpdated) return false
875+ // For MessageUpdated, only an in-flight *assistant* message counts as live activity.
876+ // A UserMessage or UnknownMessage update (e.g. a metadata refresh of an existing user
877+ // message) would otherwise add the session to activeRuns with no following SessionIdle
878+ // to clear it — pinning anyRunActive true and the foreground notification indefinitely.
879+ // Mirrors SessionRepository.isRunActivity's guard exactly.
880+ if (event is MessageUpdated) {
881+ val info = event.properties.info ?: return false
882+ return info is AssistantMessage && !info.isComplete && info.error == null
883+ }
884+ // A step-finish part is the trailing completion marker for a run (it carries the
885+ // final cost/token totals) and arrives after the last stream delta. Like a completed
886+ // message.updated, treating it as live would re-add the session to activeRuns with no
887+ // following SessionIdle to clear it — pinning anyRunActive true indefinitely when such
888+ // a part is replayed/reordered after the run already went idle.
889+ val part = (event as MessagePartUpdated).properties.part
890+ return part !is StepFinishPart
891+ }
892+
893+ /** Snapshot [activeRuns] into [_runningSessionIds] and derive [_anyRunActive] from it.
894+ * MUST be called under the [activeRuns] monitor so the published flag and snapshot can't
895+ * diverge from the set a concurrent SSE add()/remove() is mutating — the same invariant
896+ * [connectLocked]/[teardownActiveLocked] rely on. */
897+ private fun publishRunState() {
898+ _anyRunActive.value = activeRuns.isNotEmpty()
899+ _runningSessionIds.value = activeRuns.toSet()
900+ }
901+
902+ /** Session ids currently streaming an assistant run (best-effort, in-process). Backed by
903+ * an access-order [java.util.LinkedHashMap] so re-adding an already-present id on each
904+ * streaming event refreshes its recency (a plain LinkedHashSet.add is a no-op that does
905+ * NOT reorder). Entries are drained by [SessionIdle] (and cleared wholesale on reconnect
906+ * reconcile); we intentionally do NOT cap the set, because evicting the eldest would drop
907+ * its completion notification when its SessionIdle eventually arrives — a busy server
908+ * with many concurrent runs is still bounded by the server's own concurrency, so growth
909+ * is not unbounded in practice. */
910+ private val activeRuns: MutableSet<String> = java.util.Collections.synchronizedSet(
911+ java.util.Collections.newSetFromMap(java.util.LinkedHashMap<String, Boolean>(16, 0.75f, true)),
912+ )
913+
914+ /** Per non-viewed session, the set of message ids already reflected in its unread
915+ * count. De-duplicates the many streaming events that share one message id so the
916+ * badge counts messages, not deltas. Cleared when a session is viewed or on connect. */
917+ private val unreadMessageIds = java.util.concurrent.ConcurrentHashMap<String, MutableSet<String>>()
918+
919+ /** Guards connect/disconnect so concurrent callers can't leak an old connection. */
920+ private val connectionMutex = Mutex()
921+
922+ /** Only persist the `lastUsed` timestamp if it's older than this, to avoid
923+ * a DataStore + encrypted-prefs write on every rapid reconnect. */
924+ private companion object {
925+ const val LAST_USED_SAVE_THRESHOLD_MS = 60_000L
926+ }
927+
928+ /** Resolve the human-readable title for [sessionId] (best-effort), falling back to the id.
929+ * Uses [originRepo] — the repository of the connection that emitted the event — so a
930+ * server switch during the listSessions() round-trip doesn't resolve the title against
931+ * an unrelated server (showing an opaque id, or worse, a colliding session's title). */
932+ private suspend fun resolveSessionTitle(sessionId: String, originRepo: SessionRepository): String =
933+ runCatchingCancellable {
934+ originRepo.listSessions()?.firstOrNull { it.id == sessionId }?.displayTitle
935+ }.getOrNull() ?: sessionId
936+
937+ /** Resolve the title for [sessionId] (best-effort) and post a completion notification.
938+ * [originProfileId] is the id of the server that ran the session; it's embedded in the
939+ * inline-reply action so a follow-up routes back to THIS server even after the user switches
940+ * connections (mirrors [notifyPermission]). */
941+ private suspend fun notifySessionCompleted(sessionId: String, originRepo: SessionRepository, originProfileId: String) {
942+ val title = resolveSessionTitle(sessionId, originRepo)
943+ // Re-check viewing right before posting: the call site checks !isActivelyViewing(sid)
944+ // at event time, but resolveSessionTitle() suspends on a listSessions() round-trip.
945+ // If the user opened the session in that gap, setCurrentSession already cancelled the
946+ // prior notification — posting a fresh one here would leave a "session ready" heads-up
947+ // lingering while they're already looking at the finished run.
948+ if (isActivelyViewing(sessionId)) return
949+ // Suppress the completion notification for muted sessions — the whole point of muting
950+ // is that a noisy conversation doesn't interrupt.
951+ if (sessionId in _mutedSessions.value) return
952+ appContext?.let { SessionNotifications.postCompleted(it, sessionId, title, originProfileId) }
953+ }
954+
955+ /** Post a heads-up permission notification for [permission], resolving its session title.
956+ * [originConn] is the connection that emitted the permission event — its profile id is
957+ * embedded in the notification so the receiver routes the response back to THIS server. */
958+ private suspend fun notifyPermission(permission: Permission, originConn: OpencodeConnection) {
959+ val sessionId = permission.sessionID.takeIf { it.isNotBlank() } ?: return
960+ val title = resolveSessionTitle(sessionId, originConn.repository)
961+ // Re-check viewing just before posting — see notifySessionCompleted for the race.
962+ if (isActivelyViewing(sessionId)) return
963+ appContext?.let { SessionNotifications.postPermission(it, permission, title, originConn.profile.id) }
964+ }
965+
966+ /** Post an error notification for a failed background run. [originProfileId] is embedded
967+ * in the tap intent so the session opens under the server that ran it, not whichever
968+ * is active when the user taps — mirroring [notifySessionCompleted]. */
969+ private suspend fun notifySessionError(sessionId: String, originRepo: SessionRepository, originProfileId: String) {
970+ val title = resolveSessionTitle(sessionId, originRepo)
971+ // Re-check viewing just before posting — see notifySessionCompleted for the race.
972+ if (isActivelyViewing(sessionId)) return
973+ appContext?.let { SessionNotifications.postError(it, sessionId, title, originProfileId) }
974+ }
975+
976+ /** Respond to a permission from a notification action (Allow once / Always / Reject),
977+ * off any UI. Runs on the process-lived app scope; [onDone] fires when the call resolves
978+ * so the receiver's goAsync() result can finish. Its Boolean is true only when a live
979+ * connection existed AND respondPermission succeeded — the receiver uses it to decide
980+ * whether to dismiss the notification. A missing connection makes the respond a no-op,
981+ * so it must report failure (not success) rather than let the notification vanish with
982+ * the tool left unanswered.
983+ *
984+ * [profileId] is the id of the server that posted the permission request (embedded in
985+ * the notification's PendingIntent). If it doesn't match the currently active
986+ * connection's profile, the response is NOT sent: posting it to a different server
987+ * would 404 (treated as "already resolved" success by respondPermission), dismissing
988+ * the notification while the original server's tool stays paused forever. Reporting
989+ * failure instead leaves the notification up so the user can switch back and retry. */
990+ open fun respondToPermissionFromNotification(
991+ sessionId: String,
992+ permissionId: String,
993+ response: PermissionResponse,
994+ profileId: String?,
995+ onDone: (Boolean) -> Unit,
996+ ) {
997+ appScope.launch {
998+ // Guarantee onDone (so the BroadcastReceiver's goAsync() finishes) even if the
999+ // scope is cancelled mid-call (process shutdown cancels appScope first). Without
1000+ // this, a cancelled scope leaves pending.finish() never called → a receiver ANR
1001+ // and the permission response silently lost.
1002+ var success = false
1003+ try {
1004+ success = runCatchingCancellable {
1005+ val conn = activeConnection.value ?: return@runCatchingCancellable false
1006+ // Route to the originating server only. A mismatch means the user
1007+ // switched servers while the permission was pending; sending the reply
1008+ // to the wrong server would be silently swallowed as a 404-success.
1009+ if (profileId != null && conn.profile.id != profileId) return@runCatchingCancellable false
1010+ conn.api.respondPermission(sessionId, permissionId, response)
1011+ true
1012+ }.onFailure {
1013+ Log.w("AppContainer", "notif permission respond failed: ${safeExceptionSummary(it)}")
1014+ }.getOrDefault(false)
1015+ } finally {
1016+ onDone(success)
1017+ }
1018+ }
1019+ }
1020+
1021+ /** Send a follow-up prompt from a notification's inline reply, off any UI. Uses the
1022+ * server's default model/agent. Routes through the durable [outboxStore] — the same path
1023+ * the in-app composer uses ([soy.iko.opencode.ui.chat.ChatViewModel.enqueueOffline]) —
1024+ * rather than a direct repository send, so a reply composed while disconnected survives to
1025+ * be flushed on reconnect instead of being silently dropped. [flushOutbox] nudges an
1026+ * immediate send when a connection is live, so an online reply still goes out now. [onDone]
1027+ * reports whether the reply was durably enqueued so the receiver only dismisses the
1028+ * notification on success. */
1029+ open fun sendPromptFromNotification(sessionId: String, text: String, originProfileId: String?, onDone: (Boolean) -> Unit) {
1030+ appScope.launch {
1031+ // Guarantee onDone (so the BroadcastReceiver's goAsync() finishes) even if the
1032+ // scope is cancelled mid-call — see respondToPermissionFromNotification above.
1033+ var enqueued = false
1034+ try {
1035+ // Attribute to the profile that POSTED the notification (embedded in the reply
1036+ // intent) so the follow-up is always enqueued against the server that owns
1037+ // [sessionId] — never whichever server happens to be active now, which would POST
1038+ // A's session id to B, 404, and silently drop the reply. Fall back to the active/
1039+ // most-recent profile only for a legacy intent that predates the embedded id.
1040+ val profileId = originProfileId
1041+ ?: activeConnection.value?.profile?.id
1042+ ?: runCatchingCancellable { profileStore.profiles.first().firstOrNull()?.id }.getOrNull()
1043+ enqueued = if (profileId == null) {
1044+ false
1045+ } else {
1046+ runCatchingCancellable {
1047+ outboxStore.enqueue(
1048+ OutboxMessage(
1049+ id = java.util.UUID.randomUUID().toString(),
1050+ profileId = profileId,
1051+ sessionId = sessionId,
1052+ text = text,
1053+ model = null,
1054+ createdAt = System.currentTimeMillis(),
1055+ ),
1056+ )
1057+ }.onFailure {
1058+ Log.w("AppContainer", "notif reply enqueue failed: ${safeExceptionSummary(it)}")
1059+ }.isSuccess
1060+ }
1061+ if (enqueued) flushOutbox()
1062+ } finally {
1063+ onDone(enqueued)
1064+ }
1065+ }
1066+ }
1067+
1068+ /** Abort the active run for [sessionId] from a notification Stop action. Routes to the
1069+ * originating profile only — sending the abort to a different server would 404 (silently
1070+ * swallowed) and leave the original run stranded. A profile mismatch reports failure so the
1071+ * notification stays up and the user can switch back, mirroring
1072+ * [respondToPermissionFromNotification]'s routing guard. Runs on the app scope so it
1073+ * survives the BroadcastReceiver's lifetime. */
1074+ open fun abortRunFromNotification(sessionId: String, originProfileId: String?, onDone: (Boolean) -> Unit) {
1075+ appScope.launch {
1076+ var success = false
1077+ try {
1078+ val conn = activeConnection.value?.takeIf {
1079+ originProfileId == null || it.profile.id == originProfileId
1080+ }
1081+ if (conn != null) {
1082+ success = runCatchingCancellable { conn.repository.abort(sessionId) }.isSuccess
1083+ }
1084+ } finally {
1085+ onDone(success)
1086+ }
1087+ }
1088+ }
1089+
1090+ /**
1091+ * Retry the last user prompt for [sessionId] from an error notification's "Retry last"
1092+ * action. Fetches the session's messages, re-sends the most recent user prompt, and
1093+ * cancels the error notification on success. Routes to the originating profile's
1094+ * connection only — sending the retry to a different server would 404 or start a fresh
1095+ * run on an unrelated session id. A profile mismatch reports failure so the notification
1096+ * stays up and the user can switch back, mirroring [respondToPermissionFromNotification]'s
1097+ * routing guard. A no-op (reported as false) when there's no connection or no prior prompt.
1098+ */
1099+ open fun retryLastFromNotification(sessionId: String, originProfileId: String?, onDone: (Boolean) -> Unit) {
1100+ appScope.launch {
1101+ var success = false
1102+ try {
1103+ val conn = activeConnection.value?.takeIf {
1104+ originProfileId == null || it.profile.id == originProfileId
1105+ }
1106+ if (conn != null) {
1107+ success = runCatchingCancellable {
1108+ val messages = conn.api.listMessages(sessionId)
1109+ val lastPrompt = messages
1110+ .lastOrNull { it.info is soy.iko.opencode.data.model.UserMessage }
1111+ ?.parts
1112+ ?.filterIsInstance<soy.iko.opencode.data.model.TextPart>()
1113+ ?.joinToString("\n\n") { it.text }
1114+ ?.takeIf { it.isNotBlank() }
1115+ if (lastPrompt != null) {
1116+ // model=null lets the server reuse the session's current model; we
1117+ // don't know the originally-selected model from a notification context.
1118+ conn.repository.sendPrompt(sessionId, lastPrompt, model = null)
1119+ true
1120+ } else {
1121+ false
1122+ }
1123+ }.isSuccess
1124+ }
1125+ } finally {
1126+ onDone(success)
1127+ }
1128+ }
1129+ }
1130+
1131+ /** Extract the session id an event pertains to, for message-activity events. */
1132+ private fun sessionOf(event: BusEvent): String? = sessionOfEvent(event)
1133+
1134+ /**
1135+ * On cold start, transparently reconnect to the most recently used server so a
1136+ * returning user lands in their session list instead of the empty server screen.
1137+ */
1138+ private suspend fun autoConnect() {
1139+ val recent = runCatchingCancellable { profileStore.profiles.first() }
1140+ .getOrDefault(emptyList())
1141+ .firstOrNull() ?: return
1142+ // The profile read above suspends on DataStore. A manual connect() to a different
1143+ // server during that window establishes a connection the user explicitly chose; we must
1144+ // not clobber it by connecting to `recent` on top. connectIfIdle() checks "is anything
1145+ // active?" and connects atomically under connectionMutex, returning null if the cold-start
1146+ // slot was already taken — closing the TOCTOU window an unlocked null-check plus connect()'s
1147+ // unconditional close would leave. (disconnectIf below similarly guards the failed-ping
1148+ // teardown by identity.)
1149+ _reconnecting.value = true
1150+ var conn: OpencodeConnection? = null
1151+ try {
1152+ val ok = runCatchingCancellable {
1153+ conn = connectIfIdle(recent) ?: return@runCatchingCancellable false
1154+ conn!!.api.ping()
1155+ true
1156+ }.getOrDefault(false)
1157+ when {
1158+ conn == null -> Unit // slot already taken by a manual connect — leave it be
1159+ ok -> _autoConnectDone.value = true
1160+ else -> disconnectIf(conn!!)
1161+ }
1162+ } finally {
1163+ // Reset in a finally so a CancellationException (e.g. scope cancelled during
1164+ // shutdown) can't leave the flag pinned true — runCatchingCancellable rethrows it,
1165+ // skipping the line below the try block.
1166+ _reconnecting.value = false
1167+ }
1168+ }
1169+
1170+ /**
1171+ * When the device regains connectivity, nudge the active SSE stream to reconnect
1172+ * immediately instead of waiting out the exponential backoff.
1173+ */
1174+ private val networkCallback: ConnectivityManager.NetworkCallback? = registerNetworkMonitor()
1175+
1176+ private fun registerNetworkMonitor(): ConnectivityManager.NetworkCallback? {
1177+ if (appContext == null) return null
1178+ val cm = appContext!!.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
1179+ ?: return null
1180+ // Seed the online state from the current active network so the indicator is
1181+ // correct on cold start rather than defaulting to "online" until a callback fires.
1182+ _isOnline.value = runCatching { cm.activeNetwork != null }.getOrDefault(true)
1183+ val callback = object : ConnectivityManager.NetworkCallback() {
1184+ override fun onAvailable(network: Network) { _isOnline.value = true; activeConnection.value?.events?.triggerReconnect() }
1185+ // A matched (internet-capable) network was lost. Don't eagerly flip offline: during a
1186+ // Wi-Fi→cellular handoff onLost(WiFi) fires before onAvailable(cellular) and
1187+ // cm.activeNetwork is transiently null in that window — flashing offline there would
1188+ // skip outbox flushing and blink the indicator. Instead re-check activeNetwork after a
1189+ // short grace period and only report offline if there's still no active network.
1190+ //
1191+ // This MUST live in onLost, not onUnavailable: onUnavailable is delivered only for
1192+ // requestNetwork() with a timeout, never for registerNetworkCallback() — relying on it
1193+ // (as this once did) meant offline was never detected after the first onAvailable.
1194+ override fun onLost(network: Network) {
1195+ appScope.launch {
1196+ delay(NetworkConfig.networkOfflineGraceMs)
1197+ _isOnline.value = runCatching { cm.activeNetwork != null }.getOrDefault(true)
1198+ }
1199+ }
1200+ }
1201+ // Only match networks that actually provide internet — a capability-less request
1202+ // fires onAvailable for transports that can't reach the server (and would trigger
1203+ // spurious reconnects), so require NET_CAPABILITY_INTERNET.
1204+ val request = NetworkRequest.Builder()
1205+ .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
1206+ .build()
1207+ runCatching { cm.registerNetworkCallback(request, callback) }
1208+ .onFailure { Log.w("AppContainer", "Failed to register network callback", it) }
1209+ return callback
1210+ }
1211+
1212+ /** Open (or re-open) a connection to [profile], replacing any current one.
1213+ * Only persists the updated `lastUsed` timestamp if it's stale (older than a
1214+ * minute), so rapid reconnect storms don't each trigger a DataStore write. */
1215+ open suspend fun connect(profile: ServerProfile): OpencodeConnection =
1216+ connectionMutex.withLock { connectLocked(profile) }
1217+
1218+ /** Connect to the profile with [profileId], looking it up from [profileStore]. Returns
1219+ * true on success, false if the profile wasn't found or connect threw. Used by the
1220+ * notification body-tap path to route a session open back to the originating server. */
1221+ open suspend fun connectByProfileId(profileId: String): Boolean {
1222+ val profile = profileStore.profiles.first().find { it.id == profileId } ?: return false
1223+ return runCatchingCancellable { connect(profile) }.isSuccess
1224+ }
1225+
1226+ /** Connect to [profile] only if nothing is currently active, checking-and-connecting
1227+ * atomically under [connectionMutex]. Returns null (touching nothing) when a connection is
1228+ * already active, so autoConnect() can't clobber a manual connect() the user made during its
1229+ * suspending profile read — closing the TOCTOU window an unlocked null-check would leave. */
1230+ private suspend fun connectIfIdle(profile: ServerProfile): OpencodeConnection? =
1231+ connectionMutex.withLock {
1232+ if (_activeConnection.value != null) null else connectLocked(profile)
1233+ }
1234+
1235+ /** Build and install a connection to [profile], replacing any current one. Caller MUST hold
1236+ * [connectionMutex]. */
1237+ private suspend fun connectLocked(profile: ServerProfile): OpencodeConnection {
1238+ // Capture the previous profile id before closing, so we can preserve unread badges
1239+ // when reconnecting to the same server — a brief network blip or manual reconnect
1240+ // shouldn't erase "5 unread". Only a genuine server switch clears them.
1241+ val previousProfileId = _activeConnection.value?.profile?.id
1242+ _activeConnection.value?.close()
1243+ _activeConnection.value = null
1244+ // Mutate the run set and its derived flag together under activeRuns' monitor: the SSE
1245+ // event handler adds ids under the same lock, so an unlocked clear() here could
1246+ // interleave with an add()+flag=true and leave _anyRunActive pinned true with a torn-
1247+ // down connection and no SessionIdle to clear it — a stuck foreground service.
1248+ synchronized(activeRuns) {
1249+ activeRuns.clear()
1250+ publishRunState()
1251+ }
1252+ if (previousProfileId != profile.id) {
1253+ _unread.value = emptyMap()
1254+ unreadMessageIds.clear()
1255+ }
1256+ val now = System.currentTimeMillis()
1257+ val resolved = profileStore.resolve(profile)
1258+ val needsSave = (now - resolved.lastUsed) > LAST_USED_SAVE_THRESHOLD_MS
1259+ val finalProfile = if (needsSave) resolved.copy(lastUsed = now) else resolved
1260+ if (needsSave) profileStore.save(finalProfile)
1261+ return OpencodeConnection(finalProfile, messageCacheStore).also { _activeConnection.value = it }
1262+ }
1263+
1264+ open suspend fun disconnect() {
1265+ // Acquire the connection mutex so disconnect is serialized with connect().
1266+ // Previously this was fire-and-forget (launch on appScope), which raced with
1267+ // a subsequent connect(): connect() could acquire the mutex, create a new
1268+ // connection, and then the pending disconnect() would close it.
1269+ connectionMutex.withLock { teardownActiveLocked() }
1270+ }
1271+
1272+ /** Tear down [expected] only if it is still the active connection, with the identity
1273+ * check performed *under* the mutex. A caller that checks `activeConnection.value === x`
1274+ * itself and then calls [disconnect] has a TOCTOU race: a concurrent connect() to a
1275+ * different server can replace the active connection in the gap, and [disconnect] would
1276+ * then close that new connection. autoConnect()'s failed-ping teardown uses this. */
1277+ private suspend fun disconnectIf(expected: OpencodeConnection) {
1278+ connectionMutex.withLock {
1279+ if (_activeConnection.value === expected) teardownActiveLocked()
1280+ }
1281+ }
1282+
1283+ /** Close and reset the active connection. Caller MUST hold [connectionMutex]. */
1284+ private suspend fun teardownActiveLocked() {
1285+ _activeConnection.value?.close()
1286+ _activeConnection.value = null
1287+ // Reset run/unread state on disconnect too. With no SSE stream there's no
1288+ // SessionIdle to drain activeRuns, so without this the working indicator
1289+ // (anyRunActive) and unread badges would stay pinned until the next connect().
1290+ // Mutate under activeRuns' monitor (see connect()) so a concurrent SSE add() can't
1291+ // re-pin _anyRunActive after the clear.
1292+ synchronized(activeRuns) {
1293+ activeRuns.clear()
1294+ publishRunState()
1295+ }
1296+ _unread.value = emptyMap()
1297+ unreadMessageIds.clear()
1298+ }
1299+
1300+ /** Deferred profile deletions keyed by id, run on the process-lived [appScope] (not a
1301+ * ViewModel scope) so navigating away during the undo window still commits the delete
1302+ * instead of silently cancelling it — mirroring [CrashLogger.scheduleDelete]. */
1303+ private val pendingProfileDeletes = java.util.concurrent.ConcurrentHashMap<String, Job>()
1304+
1305+ /**
1306+ * Schedule the profile [id] for deletion after [delayMs], cancellable via
1307+ * [cancelProfileDelete] (the Undo action). [onDeleted]/[onError] run on [appScope]
1308+ * after the delete resolves so the caller can clear its optimistic-hide state. A
1309+ * repeated schedule for the same id replaces the prior timer.
1310+ */
1311+ open fun scheduleProfileDelete(
1312+ id: String,
1313+ delayMs: Long,
1314+ onDeleted: () -> Unit,
1315+ onError: (Throwable) -> Unit,
1316+ ) {
1317+ val job = appScope.launch {
1318+ delay(delayMs)
1319+ // Claim the delete by removing our own entry BEFORE committing it — the atomic
1320+ // remove is the sync point with cancelProfileDelete. Losing the race (an undo
1321+ // removed us first) means we skip, honouring cancel's `true`; deleting first
1322+ // would let a cancel land in the gap and falsely report undo of a gone profile.
1323+ // remove(id, thisJob) also no-ops for a stale job after a reschedule.
1324+ val claimed = coroutineContext[Job]?.let { pendingProfileDeletes.remove(id, it) } == true
1325+ if (!claimed) return@launch
1326+ runCatchingCancellable { profileStore.delete(id) }
1327+ .onSuccess { onDeleted() }
1328+ .onFailure { onError(it) }
1329+ }
1330+ pendingProfileDeletes[id]?.cancel()
1331+ pendingProfileDeletes[id] = job
1332+ }
1333+
1334+ /** Cancel a pending deferred profile delete (the Undo action). Returns true if it was
1335+ * still pending (undo succeeded), false if the delete had already fired. */
1336+ open fun cancelProfileDelete(id: String): Boolean {
1337+ val job = pendingProfileDeletes.remove(id) ?: return false
1338+ job.cancel()
1339+ return true
1340+ }
1341+
1342+ /** Deferred session deletions keyed by id, run on the process-lived [appScope] (not a
1343+ * ViewModel scope) so navigating away during the undo window still commits the delete
1344+ * instead of silently cancelling it (which would leave the session deleted-in-UI but
1345+ * alive on the server, reappearing on the next refresh). */
1346+ private val pendingSessionDeletes = java.util.concurrent.ConcurrentHashMap<String, Job>()
1347+
1348+ /** One-shot events carrying the id of a session scheduled for deferred deletion from
1349+ * outside the session list (e.g. from the chat screen's Delete). The SessionListScreen
1350+ * collects these to show an Undo snackbar, mirroring its own VM-level undo events. */
1351+ private val _externalSessionUndoEvents = MutableSharedFlow<String>(
1352+ extraBufferCapacity = NetworkConfig.snackbarEventBufferCapacity,
1353+ onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST,
1354+ )
1355+ val externalSessionUndoEvents: SharedFlow<String> = _externalSessionUndoEvents.asSharedFlow()
1356+
1357+ /** Emit an undo event for a session delete initiated outside the session list (e.g.
1358+ * from the chat screen). The SessionListScreen collects these to show an Undo snackbar. */
1359+ fun emitExternalSessionUndo(sessionId: String) {
1360+ _externalSessionUndoEvents.tryEmit(sessionId)
1361+ }
1362+
1363+ /**
1364+ * Schedule session [id] for deletion after [delayMs], cancellable via [cancelSessionDelete]
1365+ * (the Undo action). Also clears the session's stored draft on a successful delete.
1366+ * [onDeleted]/[onError] run on [appScope] after the delete resolves so the caller can update
1367+ * its optimistic-hide state. A repeated schedule for the same id replaces the prior timer.
1368+ *
1369+ * The owning connection is captured now, at schedule time — NOT re-resolved when the timer
1370+ * fires. Otherwise a server switch during the undo window would send the delete to whichever
1371+ * server happens to be active at fire time: the original session would never be deleted (its
1372+ * row reappears on the next refresh) and, in the rare id-collision case, an unrelated session
1373+ * on the new server could be deleted instead. A disconnect closes the captured connection's
1374+ * HTTP client, so a delete deferred across a disconnect fails and is surfaced via [onError]
1375+ * (the session cannot be deleted on a server we're no longer connected to). No-ops (as an
1376+ * error) if there is no active connection at schedule time.
1377+ */
1378+ open fun scheduleSessionDelete(
1379+ id: String,
1380+ delayMs: Long,
1381+ onDeleted: () -> Unit,
1382+ onError: (Throwable) -> Unit,
1383+ ) {
1384+ val conn = _activeConnection.value
1385+ val job = appScope.launch {
1386+ delay(delayMs)
1387+ // Claim the delete by removing our own entry BEFORE committing it — the atomic
1388+ // remove is the sync point with cancelSessionDelete. Losing the race (an undo
1389+ // removed us first) means we skip, honouring cancel's `true`; deleting first
1390+ // would let a cancel land in the gap and falsely report undo of a gone session.
1391+ // remove(id, thisJob) also no-ops for a stale job after a reschedule.
1392+ val claimed = coroutineContext[Job]?.let { pendingSessionDeletes.remove(id, it) } == true
1393+ if (!claimed) return@launch
1394+ if (conn == null) {
1395+ onError(IllegalStateException("No active connection"))
1396+ } else {
1397+ runCatchingCancellable { conn.repository.deleteSession(id) }
1398+ .onSuccess {
1399+ draftStore.remove(id)
1400+ attachmentDraftStore.remove(id)
1401+ sessionPrefsStore.forget(id)
1402+ messageCacheStore.remove(conn.profile.id, id)
1403+ outboxStore.removeForSession(id)
1404+ clearUnread(id)
1405+ onDeleted()
1406+ }
1407+ .onFailure { onError(it) }
1408+ }
1409+ }
1410+ pendingSessionDeletes[id]?.cancel()
1411+ pendingSessionDeletes[id] = job
1412+ }
1413+
1414+ /** Cancel a pending deferred session delete (the Undo action). Returns true if it was
1415+ * still pending (undo succeeded), false if the delete had already fired. */
1416+ open fun cancelSessionDelete(id: String): Boolean {
1417+ val job = pendingSessionDeletes.remove(id) ?: return false
1418+ job.cancel()
1419+ return true
1420+ }
1421+
1422+ /**
1423+ * Probe a server URL (without credentials) to check reachability and detect whether
1424+ * the server requires authentication. Builds a short-lived HTTP client with the same
1425+ * base URL normalization as [connect] but no auth, calls the health endpoint, and
1426+ * classifies the outcome:
1427+ *
1428+ * - 2xx → [ProbeResult.Reachable] (no auth needed)
1429+ * - 401/403 → [ProbeResult.NeedsAuth]
1430+ * - anything else → [ProbeResult.Unreachable] with a user-facing message
1431+ *
1432+ * The probe client is always closed afterwards so no resources linger. This does not
1433+ * touch the active connection or the profile store.
1434+ */
1435+ open suspend fun probeServer(baseUrl: String, requireHttps: Boolean, certPin: String?): ProbeResult {
1436+ return probeWithProfile(ServerProfile(
1437+ id = "probe",
1438+ label = "",
1439+ baseUrl = baseUrl.trim(),
1440+ username = null,
1441+ password = null,
1442+ // Probe the same effective URL save()/connect() will use: HttpClientFactory upgrades
1443+ // http->https and installs the cert pinner when these are set, so a probe without them
1444+ // would validate a plain-http endpoint the real connection never talks to.
1445+ requireHttps = requireHttps,
1446+ certPin = certPin,
1447+ ))
1448+ }
1449+
1450+ /**
1451+ * Probe a server URL *with* credentials to validate them. Returns true if the
1452+ * server accepts the credentials (ping succeeds), false on 401/403, and throws
1453+ * on other failures (unreachable, timeout) so the caller can surface a friendly
1454+ * message. Mirrors [probeServer]'s short-lived-client pattern.
1455+ */
1456+ open suspend fun probeWithCredentials(
1457+ baseUrl: String,
1458+ username: String,
1459+ password: String,
1460+ requireHttps: Boolean,
1461+ certPin: String?,
1462+ ): Boolean {
1463+ val probeProfile = ServerProfile(
1464+ id = "probe-auth",
1465+ label = "",
1466+ baseUrl = baseUrl.trim(),
1467+ username = username.trim().takeIf { it.isNotBlank() },
1468+ password = password.trim().takeIf { it.isNotEmpty() },
1469+ // Match the effective URL/pinning save()/connect() will use (see probeServer).
1470+ requireHttps = requireHttps,
1471+ certPin = certPin,
1472+ )
1473+ val client = HttpClientFactory.create(probeProfile)
1474+ return try {
1475+ val api = OpencodeApiClient(client)
1476+ api.ping()
1477+ true
1478+ } catch (c: kotlinx.coroutines.CancellationException) {
1479+ throw c
1480+ } catch (e: Exception) {
1481+ val status = responseStatusCode(e)
1482+ if (status == 401 || status == 403) false else throw e
1483+ } finally {
1484+ // runCatchingCancellable (not plain runCatching): this is a suspend finally, and
1485+ // plain runCatching swallows CancellationException — if the caller's scope is
1486+ // cancelled while client.close() is in flight, the cancellation must propagate.
1487+ runCatchingCancellable { client.close() }
1488+ }
1489+ }
1490+
1491+ private suspend fun probeWithProfile(profile: ServerProfile): ProbeResult {
1492+ val client = HttpClientFactory.create(profile)
1493+ return try {
1494+ val api = OpencodeApiClient(client)
1495+ api.ping()
1496+ ProbeResult.Reachable
1497+ } catch (c: kotlinx.coroutines.CancellationException) {
1498+ throw c
1499+ } catch (e: Exception) {
1500+ val status = responseStatusCode(e)
1501+ if (status == 401 || status == 403) {
1502+ ProbeResult.NeedsAuth
1503+ } else {
1504+ ProbeResult.Unreachable(friendlyErrorFor(e, profile.baseUrl))
1505+ }
1506+ } finally {
1507+ // runCatchingCancellable: see probeWithCredentials for the rationale.
1508+ runCatchingCancellable { client.close() }
1509+ }
1510+ }
1511+
1512+ // Declared last so every property above is fully initialized before these observers start.
1513+ // They only launch coroutines into appScope (Dispatchers.IO) — nothing here runs
1514+ // synchronously during construction — so placing init at the end removes any dependence on
1515+ // the textual declaration order of the fields those observers reference.
1516+ init {
1517+ if (!skipInit) {
1518+ NotificationChannels.create(appContext!!)
1519+ observeMessageActivity()
1520+ observeRunReconcileOnReconnect()
1521+ observeRunForegroundService()
1522+ observeBackgroundedConnectionFailures()
1523+ // Load the persisted muted-sessions set so isSessionMuted() reflects the choice
1524+ // immediately on cold start (before any session list composition).
1525+ appScope.launch {
1526+ runCatchingCancellable {
1527+ sessionPrefsStore.muted.collect { ids -> _mutedSessions.value = ids }
1528+ }
1529+ }
1530+ // Flush right after load() populates the queue. observeOutbox()'s combine is keyed
1531+ // on activeConnection/isOnline/_outboxFlushTrigger — NOT on outboxStore.messages — so
1532+ // if connect() sets activeConnection before load() finishes reading a (possibly
1533+ // multi-MB) outbox.json, the connect-triggered flush sees an empty queue and nothing
1534+ // re-triggers it. Nudging the flush here guarantees a loaded queue is sent. Log a
1535+ // load failure so a corrupt outbox.json / IO error is debuggable rather than silently
1536+ // dropping every queued offline message until a future restart happens to succeed.
1537+ appScope.launch {
1538+ runCatchingCancellable { outboxStore.load() }
1539+ .onFailure { Log.w("AppContainer", "Outbox load failed; queued messages may be lost: ${safeExceptionSummary(it)}") }
1540+ flushOutbox()
1541+ }
1542+ observeOutbox()
1543+ appScope.launch { autoConnect() }
1544+ }
1545+ }
1546+}
1547+
1548+/**
1549+ * Extract the session id a message-activity event pertains to, or null for events that
1550+ * don't describe message activity. This drives the unread badge: any session that
1551+ * receives activity while it isn't the currently-viewed one gets badged. Extracted as
1552+ * a top-level `internal` function so the rule is unit-testable without an Android
1553+ * [Context] (the surrounding [AppContainer] needs one).
1554+ */
1555+internal fun sessionOfEvent(event: BusEvent): String? = when (event) {
1556+ is MessageUpdated -> event.properties.info?.sessionID
1557+ is MessagePartUpdated -> event.properties.part.sessionID ?: event.properties.sessionID
1558+ else -> null
1559+}
1560+
1561+/**
1562+ * The message id a message-activity event pertains to, used to de-duplicate the unread
1563+ * badge so a reply's many streaming events count once. Null for events that carry no
1564+ * message id (those fall back to being counted individually).
1565+ */
1566+internal fun messageIdOfEvent(event: BusEvent): String? = when (event) {
1567+ is MessageUpdated -> event.properties.info?.id
1568+ is MessagePartUpdated -> event.properties.messageID ?: event.properties.part.messageID
1569+ else -> null
1570+}
app/src/main/java/soy/iko/opencode/di/OpencodeConnection.ktnew file+52
@@ -0,0 +1,52 @@
1+package soy.iko.opencode.di
2+
3+import soy.iko.opencode.data.model.ServerProfile
4+import soy.iko.opencode.data.network.EventStreamClient
5+import soy.iko.opencode.data.network.HttpClientFactory
6+import soy.iko.opencode.data.network.OpencodeApiClient
7+import soy.iko.opencode.data.repo.MessageCacheStore
8+import soy.iko.opencode.data.repo.SessionRepository
9+import kotlinx.coroutines.CoroutineScope
10+import kotlinx.coroutines.Dispatchers
11+import kotlinx.coroutines.SupervisorJob
12+import kotlinx.coroutines.cancel
13+import soy.iko.opencode.util.runCatchingCancellable
14+
15+/**
16+ * An active connection to one opencode server. Bundles the Ktor client, REST API,
17+ * SSE event stream, and the reducing repository, all sharing a connection-scoped
18+ * coroutine scope that is cancelled on [close].
19+ */
20+open class OpencodeConnection(
21+ val profile: ServerProfile,
22+ // Optional on-disk message cache, supplied by AppContainer, so conversations render
23+ // instantly and stay readable offline. Null in the test constructor.
24+ private val messageCache: MessageCacheStore? = null,
25+) {
26+
27+ protected constructor() : this(ServerProfile(id = "", label = "", baseUrl = "http://localhost"), null)
28+
29+ private val scopeJob = SupervisorJob()
30+ private val scope by lazy { CoroutineScope(scopeJob + Dispatchers.IO) }
31+ private val client by lazy { HttpClientFactory.create(profile) }
32+
33+ open val api: OpencodeApiClient by lazy { OpencodeApiClient(client) }
34+ open val events: EventStreamClient by lazy { EventStreamClient(client, scope) }
35+ open val repository: SessionRepository by lazy { SessionRepository(api, events, messageCache, profile.id) }
36+
37+ /**
38+ * Cancel the coroutine scope and wait for in-flight work (including the SSE reader)
39+ * to wind down before closing the HTTP client. Calling client.close() while the SSE
40+ * coroutine is still mid-request produces a spurious "stream error, will retry"
41+ * warning during normal teardown.
42+ */
43+ open suspend fun close() {
44+ scope.cancel()
45+ scopeJob.join()
46+ // runCatchingCancellable (not plain runCatching) per the repo convention: close() is a
47+ // suspend function, and plain runCatching swallows CancellationException — if the
48+ // caller's scope is cancelled mid-close, the cancellation must propagate rather than
49+ // be eaten as a Result.failure (which would leave the client half-closed).
50+ runCatchingCancellable { client.close() }
51+ }
52+}
app/src/main/java/soy/iko/opencode/notification/NotificationActionReceiver.ktnew file+195
@@ -0,0 +1,195 @@
1+package soy.iko.opencode.notification
2+
3+import android.content.BroadcastReceiver
4+import android.content.Context
5+import android.content.Intent
6+import androidx.core.app.RemoteInput
7+import soy.iko.opencode.OpencodeApp
8+import soy.iko.opencode.R
9+import soy.iko.opencode.data.model.PermissionResponse
10+import soy.iko.opencode.data.network.NetworkConfig
11+import java.util.concurrent.atomic.AtomicBoolean
12+
13+/**
14+ * Handles the action buttons on the app's notifications so the user can respond without
15+ * opening the app — the core of the "kick it off and walk away" flow:
16+ *
17+ * - [ACTION_PERMISSION] with a [PermissionResponse] → replies to a paused tool's permission
18+ * request (Allow once / Always / Reject) via the active connection.
19+ * - [ACTION_REPLY] with a [RemoteInput] text payload → sends a follow-up prompt to a session
20+ * that just finished a run.
21+ *
22+ * The receiver is not exported (see the manifest); the [PendingIntent]s that target it are
23+ * created by the app itself. Work is done on the process-lived app scope via [OpencodeApp]'s
24+ * container, and [goAsync] keeps the receiver alive until the (network) call resolves.
25+ *
26+ * A watchdog finishes the [PendingResult] within [NetworkConfig.notificationReceiverTimeoutMs]
27+ * so a slow network call (e.g. a permission respond retried with exponential backoff) can't
28+ * ANR the receiver. The underlying call keeps running on the app scope; only the receiver's
29+ * lifetime is bounded. [finishOnce] guards against a double finish() (which would throw).
30+ */
31+class NotificationActionReceiver : BroadcastReceiver() {
32+
33+ override fun onReceive(context: Context, intent: Intent) {
34+ val container = (context.applicationContext as? OpencodeApp)?.container ?: return
35+ if (intent.action == ACTION_STOP_RUN) {
36+ handleStopRun(context, container, intent)
37+ return
38+ }
39+ val sessionId = intent.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() } ?: return
40+ when (intent.action) {
41+ ACTION_MARK_READ -> {
42+ // "Mark read" from a completion notification — clear the unread badge and
43+ // dismiss the notification without opening the session.
44+ container.markSessionRead(sessionId)
45+ SessionNotifications.cancel(context, sessionId)
46+ return
47+ }
48+ ACTION_PERMISSION -> {
49+ val permissionId = intent.getStringExtra(EXTRA_PERMISSION_ID)?.takeIf { it.isNotBlank() } ?: return
50+ val profileId = intent.getStringExtra(EXTRA_PROFILE_ID)?.takeIf { it.isNotBlank() }
51+ val response = PermissionResponse.entries.firstOrNull {
52+ it.wire == intent.getStringExtra(EXTRA_RESPONSE)
53+ } ?: return
54+ val pending = goAsync()
55+ val finished = AtomicBoolean(false)
56+ var watchdogThread: Thread? = null
57+ val finishOnce = {
58+ if (finished.compareAndSet(false, true)) {
59+ watchdogThread?.interrupt()
60+ pending.finish()
61+ }
62+ }
63+ watchdogThread = watchdog(finishOnce)
64+ container.respondToPermissionFromNotification(sessionId, permissionId, response, profileId) { success ->
65+ // Only dismiss the notification once the tool was actually answered. If
66+ // there was no live connection (respond is a no-op) or the call failed,
67+ // leave it up so the user can retry — otherwise it vanishes with the
68+ // permission request left unanswered.
69+ if (success) SessionNotifications.cancelPermission(context, sessionId)
70+ finishOnce()
71+ }
72+ }
73+ ACTION_REPLY -> {
74+ val text = RemoteInput.getResultsFromIntent(intent)
75+ ?.getCharSequence(KEY_REPLY_TEXT)?.toString()?.trim()
76+ ?.takeIf { it.isNotEmpty() }
77+ if (text == null) {
78+ // The RemoteInput fired but the reply was empty/whitespace. SystemUI locks the
79+ // inline field in a "sending…" state until the notification is updated or
80+ // cancelled — cancel it so the field doesn't stay stuck with nothing to send.
81+ SessionNotifications.cancel(context, sessionId)
82+ return
83+ }
84+ val profileId = intent.getStringExtra(EXTRA_PROFILE_ID)?.takeIf { it.isNotBlank() }
85+ val pending = goAsync()
86+ val finished = AtomicBoolean(false)
87+ var watchdogThread: Thread? = null
88+ val finishOnce = {
89+ if (finished.compareAndSet(false, true)) {
90+ watchdogThread?.interrupt()
91+ pending.finish()
92+ }
93+ }
94+ watchdogThread = watchdog(finishOnce)
95+ container.sendPromptFromNotification(sessionId, text, profileId) { enqueued ->
96+ // The reply is durably queued (and flushed now if online). Show a brief
97+ // "Sent" confirmation by updating the completion notification's text, then
98+ // let it auto-cancel on tap — instead of vanishing silently. If the enqueue
99+ // failed, post a "reply not sent" error: leaving the completion notification
100+ // up strands SystemUI's RemoteInput field in a permanent "sending…" spinner
101+ // that can't be resubmitted, so "leave it up to retry" doesn't actually let
102+ // the user retry.
103+ if (enqueued) {
104+ SessionNotifications.postReplySent(context, sessionId, profileId)
105+ } else {
106+ SessionNotifications.postReplyFailed(context, sessionId, profileId)
107+ }
108+ finishOnce()
109+ }
110+ }
111+ ACTION_RETRY_LAST -> {
112+ // "Retry last" from an error notification: re-send the most recent user prompt
113+ // for this session without opening the app. Cancels the error notification on
114+ // success so the user sees the retry took effect.
115+ val profileId = intent.getStringExtra(EXTRA_PROFILE_ID)?.takeIf { it.isNotBlank() }
116+ val pending = goAsync()
117+ val finished = AtomicBoolean(false)
118+ var watchdogThread: Thread? = null
119+ val finishOnce = {
120+ if (finished.compareAndSet(false, true)) {
121+ watchdogThread?.interrupt()
122+ pending.finish()
123+ }
124+ }
125+ watchdogThread = watchdog(finishOnce)
126+ container.retryLastFromNotification(sessionId, profileId) { success ->
127+ if (success) SessionNotifications.cancel(context, sessionId)
128+ finishOnce()
129+ }
130+ }
131+ }
132+ }
133+
134+ /** Handle the Stop action from the foreground-service notification. Extracted from
135+ * [onReceive] to keep that function's cyclomatic complexity under detekt's threshold. */
136+ private fun handleStopRun(context: Context, container: soy.iko.opencode.di.AppContainer, intent: Intent) {
137+ // The session id may be absent if multiple runs were active (generic notification);
138+ // in that case we can't target a specific run — just let it ride.
139+ val sessionId = intent.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() } ?: return
140+ val profileId = intent.getStringExtra(EXTRA_PROFILE_ID)?.takeIf { it.isNotBlank() }
141+ val pending = goAsync()
142+ val finished = AtomicBoolean(false)
143+ var watchdogThread: Thread? = null
144+ val finishOnce = {
145+ if (finished.compareAndSet(false, true)) {
146+ watchdogThread?.interrupt()
147+ pending.finish()
148+ }
149+ }
150+ watchdogThread = watchdog(finishOnce)
151+ container.abortRunFromNotification(sessionId, profileId) { success ->
152+ // On a failed abort (transient network error), the run continues but the FGS
153+ // notification will disappear once anyRunActive clears — leaving the user with
154+ // no signal that their Stop tap didn't take. Post a quick "couldn't stop"
155+ // notification so they know to open the app. On success, the FGS disappearing
156+ // is sufficient feedback.
157+ if (!success) {
158+ SessionNotifications.postError(
159+ context, sessionId,
160+ context.getString(R.string.couldnt_stop_run),
161+ profileId,
162+ )
163+ }
164+ finishOnce()
165+ }
166+ }
167+
168+ /** Schedule a deadline after which the receiver's [PendingResult] is finished even if
169+ * the network call hasn't resolved, so the system can't ANR the receiver. The
170+ * underlying work continues on the app scope. Returns the watchdog thread so the
171+ * caller can [interrupt][Thread.interrupt] it when work completes early, releasing
172+ * the sleeping thread immediately instead of leaving it for the full timeout. */
173+ private fun watchdog(finishOnce: () -> Unit): Thread =
174+ Thread {
175+ try {
176+ Thread.sleep(NetworkConfig.notificationReceiverTimeoutMs)
177+ } catch (_: InterruptedException) {
178+ return@Thread
179+ }
180+ finishOnce()
181+ }.apply { isDaemon = true; name = "notif-receiver-watchdog"; start() }
182+
183+ companion object {
184+ const val ACTION_PERMISSION = "soy.iko.opencode.action.PERMISSION"
185+ const val ACTION_REPLY = "soy.iko.opencode.action.REPLY"
186+ const val ACTION_STOP_RUN = "soy.iko.opencode.action.STOP_RUN"
187+ const val ACTION_MARK_READ = "soy.iko.opencode.action.MARK_READ"
188+ const val ACTION_RETRY_LAST = "soy.iko.opencode.action.RETRY_LAST"
189+ const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
190+ const val EXTRA_PERMISSION_ID = "soy.iko.opencode.extra.PERMISSION_ID"
191+ const val EXTRA_PROFILE_ID = "soy.iko.opencode.extra.PROFILE_ID"
192+ const val EXTRA_RESPONSE = "soy.iko.opencode.extra.RESPONSE"
193+ const val KEY_REPLY_TEXT = "soy.iko.opencode.key.REPLY_TEXT"
194+ }
195+}
app/src/main/java/soy/iko/opencode/notification/NotificationChannels.ktnew file+65
@@ -0,0 +1,65 @@
1+package soy.iko.opencode.notification
2+
3+import android.app.NotificationChannel
4+import android.app.NotificationManager
5+import android.content.Context
6+import soy.iko.opencode.R
7+
8+/**
9+ * The app's notification channels. Created once at startup (idempotent — recreating an
10+ * existing channel is a no-op). Two channels keep user control granular:
11+ *
12+ * - [STATUS]: low-importance, no sound. Used by the foreground service's persistent
13+ * "agent running" indicator.
14+ * - [COMPLETED]: default importance. Used when a background session finishes a run.
15+ * - [PERMISSION]: high importance (heads-up + sound). Used when a paused tool run needs
16+ * the user's approval — the whole point is to reach the user who has walked away, so it
17+ * interrupts rather than sitting silently in the shade.
18+ * - [ERROR]: default importance. Used when a background run fails.
19+ */
20+object NotificationChannels {
21+ const val STATUS = "run_status"
22+ const val COMPLETED = "session_completed"
23+ const val PERMISSION = "permission_request"
24+ const val ERROR = "session_error"
25+ const val UNREAD = "unread_badge"
26+
27+ fun create(context: Context) {
28+ val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager ?: return
29+ nm.createNotificationChannel(
30+ NotificationChannel(STATUS, context.getString(R.string.notif_channel_status), NotificationManager.IMPORTANCE_LOW).apply {
31+ description = context.getString(R.string.notif_channel_status_desc)
32+ setShowBadge(false)
33+ },
34+ )
35+ nm.createNotificationChannel(
36+ NotificationChannel(COMPLETED, context.getString(R.string.notif_channel_completed), NotificationManager.IMPORTANCE_DEFAULT).apply {
37+ description = context.getString(R.string.notif_channel_completed_desc)
38+ },
39+ )
40+ nm.createNotificationChannel(
41+ NotificationChannel(PERMISSION, context.getString(R.string.notif_channel_permission), NotificationManager.IMPORTANCE_HIGH).apply {
42+ description = context.getString(R.string.notif_channel_permission_desc)
43+ },
44+ )
45+ nm.createNotificationChannel(
46+ // HIGH importance so a failed run stands out from a completed one by
47+ // sound/vibration — both are actionable, but a failure is time-sensitive.
48+ NotificationChannel(ERROR, context.getString(R.string.notif_channel_error), NotificationManager.IMPORTANCE_HIGH).apply {
49+ description = context.getString(R.string.notif_channel_error_desc)
50+ },
51+ )
52+ // A silent, badge-only channel that carries the launcher icon's unread count (the only
53+ // way to badge the launcher icon on Android is via a notification on a badge-enabled
54+ // channel). Low importance + no sound/vibration so it never alerts; setShowBadge(true)
55+ // so its number renders on the launcher icon.
56+ nm.createNotificationChannel(
57+ NotificationChannel(UNREAD, context.getString(R.string.notif_channel_unread), NotificationManager.IMPORTANCE_MIN).apply {
58+ description = context.getString(R.string.notif_channel_unread_desc)
59+ setShowBadge(true)
60+ setSound(null, null)
61+ enableVibration(false)
62+ },
63+ )
64+ }
65+}
app/src/main/java/soy/iko/opencode/notification/RunForegroundService.ktnew file+375
@@ -0,0 +1,375 @@
1+package soy.iko.opencode.notification
2+
3+import android.app.Notification
4+import android.app.PendingIntent
5+import android.app.Service
6+import android.content.Context
7+import android.content.Intent
8+import android.content.pm.ServiceInfo
9+import android.content.res.Configuration
10+import android.os.Build
11+import android.os.IBinder
12+import android.util.Log
13+import android.annotation.SuppressLint
14+import androidx.core.app.NotificationCompat
15+import androidx.core.app.NotificationManagerCompat
16+import androidx.core.app.ServiceCompat
17+import soy.iko.opencode.MainActivity
18+import soy.iko.opencode.R
19+import java.security.MessageDigest
20+
21+/**
22+ * A foreground service kept alive while an opencode agent is actively running. Holding a
23+ * foreground priority lets the long-lived SSE `/event` subscription survive backgrounding
24+ * (Doze/app-standby would otherwise choke the socket and stall streaming mid-run).
25+ *
26+ * The service is started when a run begins ([start]) and stopped when it goes idle
27+ * ([stop]). It shows a low-importance "Agent is working…" notification in [NotificationChannels.STATUS].
28+ * Started/stopped via Compose from the chat screen based on the `running` state.
29+ */
30+class RunForegroundService : Service() {
31+
32+ // Wall-clock time the current run started, captured on the first onStartCommand of
33+ // the run and reused across progress-driven notification rebuilds so the chronometer's
34+ // base doesn't jump back to 0:00 every time the agent advances a step. Reset to 0 in
35+ // onDestroy once the run ends (stop() → onDestroy) so the next run starts fresh.
36+ //
37+ // Instance field (not companion/static): a static field would survive a stopped-then-
38+ // recreated service instance and leave the chronometer pinned to a previous run's start
39+ // if onDestroy hadn't run yet when the next run's first intent arrived. As an instance
40+ // field it's reset by construction on every new Service instance.
41+ //
42+ // @Volatile: onStartCommand/onDestroy write these on the main thread, but updateProgress
43+ // (reached via the companion start() from AppContainer.observeRunForegroundService, which
44+ // runs on Dispatchers.IO) reads them. Without @Volatile there's no happens-before edge,
45+ // so a 64-bit Long (runStartMillis) can be torn and the booleans/strings can be stale.
46+ @Volatile private var runStartMillis: Long = 0L
47+ // Whether *this* service instance has successfully called startForeground. Tracked so
48+ // progress-driven re-starts (which re-enter onStartCommand) don't re-call startForeground
49+ // and re-bind the 5s obligation each time — that would let a backgrounded re-start throw
50+ // ForegroundServiceStartNotAllowedException even though the service is already in the
51+ // foreground. After the first successful startForeground, progress updates refresh the
52+ // notification via NotificationManager.notify directly (see [updateProgress]), which
53+ // never throws the background-start exception.
54+ @Volatile private var isInForeground: Boolean = false
55+ // The most recent session title/id/profile/progress handed to onStartCommand, retained so
56+ // [updateProgress] can rebuild the notification with a new progress string without a fresh
57+ // startForegroundService intent (which can throw when backgrounded).
58+ @Volatile private var lastTitle: String? = null
59+ @Volatile private var lastSessionId: String? = null
60+ @Volatile private var lastProfileId: String? = null
61+ @Volatile private var lastProgress: String? = null
62+
63+ @android.annotation.SuppressLint("MissingPermission")
64+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
65+ instance = this
66+ // The session title (when a single run is active) is passed via the intent extra so
67+ // the notification can identify which session is running, instead of a generic
68+ // "Agent is working…". Null/blank falls back to the generic title.
69+ val sessionTitle = intent?.getStringExtra(EXTRA_SESSION_TITLE)?.takeIf { it.isNotBlank() }
70+ val sessionId = intent?.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() }
71+ val profileId = intent?.getStringExtra(EXTRA_PROFILE_ID)?.takeIf { it.isNotBlank() }
72+ val progress = intent?.getStringExtra(EXTRA_PROGRESS)?.takeIf { it.isNotBlank() }
73+ lastTitle = sessionTitle
74+ lastSessionId = sessionId
75+ lastProfileId = profileId
76+ lastProgress = progress
77+ // Capture the wall-clock start of the *first* intent of this run and reuse it across
78+ // rebuilds so the chronometer's base doesn't jump back to 0:00 every time the agent
79+ // advances a step. As an instance field it's 0 on a fresh service, so a recreated
80+ // service (after onDestroy reset) starts the clock fresh too.
81+ if (runStartMillis == 0L) runStartMillis = System.currentTimeMillis()
82+ val notification = buildNotification(sessionTitle, sessionId, profileId, progress)
83+ // Only call startForeground once per service lifetime — re-calling it on every
84+ // progress-driven re-start re-binds the "must call startForeground within 5s"
85+ // obligation each time, and if the app is backgrounded between two progress
86+ // updates the new call can throw ForegroundServiceStartNotAllowedException even
87+ // though the service is already in the foreground. That would drop foreground
88+ // priority mid-run (the catch below stopSelf()s, killing the FGS while
89+ // anyRunActive is still true). After the first successful startForeground,
90+ // progress updates refresh the notification via NotificationManager.notify
91+ // directly, which never throws the background-start exception.
92+ if (!isInForeground) {
93+ runCatching {
94+ ServiceCompat.startForeground(
95+ this,
96+ NOTIF_ID,
97+ notification,
98+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
99+ ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
100+ } else {
101+ 0
102+ },
103+ )
104+ }.onFailure {
105+ Log.w(TAG, "startForeground failed; running without foreground priority", it)
106+ stopSelf()
107+ }.also { isInForeground = it.isSuccess }
108+ } else {
109+ // Already in the foreground: refresh the notification in place. NotificationManager
110+ // updates an ongoing notification without re-asserting foreground priority.
111+ runCatching {
112+ NotificationManagerCompat.from(this).notify(NOTIF_ID, notification)
113+ }.onFailure { Log.w(TAG, "Failed to update foreground notification", it) }
114+ }
115+ return START_NOT_STICKY
116+ }
117+
118+ /** Rebuild the notification with an updated progress string and re-notify in place, *without*
119+ * re-entering [onStartCommand] (and therefore without calling [Context.startForegroundService],
120+ * which can throw [android.app.ForegroundServiceStartNotAllowedException] on Android 12+ when
121+ * the app is backgrounded — even if the service is already in the foreground). Called directly
122+ * by the companion [start] when a progress refresh is needed while the service is already
123+ * running; falls back to [start] (which goes through startForegroundService) when the
124+ * service isn't alive yet, so the first progress update still lands.
125+ *
126+ * Forwards the full set of run fields (title/sessionId/profileId/progress), not just the
127+ * progress string, so a late-resolved session title or a second run starting after the
128+ * first updates the notification text AND the Stop action's target session/profile.
129+ * Without this, the Stop action would keep targeting the originally-captured session even
130+ * after a profile switch (mirroring the SessionNotifications abort-routing guard). */
131+ @SuppressLint("MissingPermission")
132+ private fun updateProgress(
133+ progress: String?,
134+ sessionTitle: String? = lastTitle,
135+ sessionId: String? = lastSessionId,
136+ profileId: String? = lastProfileId,
137+ ) {
138+ // Guard against the companion start() → instance race: start() reads @Volatile instance
139+ // on a background dispatcher and calls updateProgress, but onDestroy (on the main thread)
140+ // can null instance and call stopForeground between the read and this notify(). isInForeground
141+ // is cleared in onDestroy, so checking it here covers the stopForeground-already-called case
142+ // and avoids touching NotificationManager during teardown.
143+ if (!isInForeground) return
144+ lastTitle = sessionTitle
145+ lastSessionId = sessionId
146+ lastProfileId = profileId
147+ lastProgress = progress
148+ val notification = buildNotification(sessionTitle, sessionId, profileId, progress)
149+ runCatching {
150+ NotificationManagerCompat.from(this).notify(NOTIF_ID, notification)
151+ }.onFailure { Log.w(TAG, "Failed to update foreground notification progress", it) }
152+ }
153+
154+ private fun mainContentIntent(): PendingIntent {
155+ // Tapping the notification opens the app so the user can see the running session.
156+ val openIntent = Intent(this, MainActivity::class.java).apply {
157+ flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
158+ }
159+ return PendingIntent.getActivity(
160+ this, 0, openIntent,
161+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
162+ )
163+ }
164+
165+ private fun buildNotification(
166+ sessionTitle: String?,
167+ sessionId: String?,
168+ profileId: String? = null,
169+ progress: String? = null,
170+ ): Notification {
171+ val pendingIntent = mainContentIntent()
172+ val contentText = progress ?: sessionTitle ?: getString(R.string.notif_running_text)
173+ val builder = NotificationCompat.Builder(this, NotificationChannels.STATUS)
174+ .setSmallIcon(R.drawable.ic_stat_notify)
175+ .setColor(brandColor(this))
176+ .setContentTitle(getString(R.string.notif_running_title))
177+ .setContentText(contentText)
178+ .setContentIntent(pendingIntent)
179+ .setOngoing(true)
180+ // Show a live elapsed timer so the user can see how long the current run has
181+ // been going — no per-tick plumbing needed; the system renders the chronometer.
182+ // runStartMillis is captured once at the start of the run (see onStartCommand)
183+ // and reused across progress-driven rebuilds so the chronometer base is stable.
184+ .setWhen(runStartMillis)
185+ .setUsesChronometer(true)
186+ .setShowWhen(true)
187+ .setPriority(NotificationCompat.PRIORITY_LOW)
188+ .setCategory(NotificationCompat.CATEGORY_PROGRESS)
189+ // An indeterminate progress bar so the notification reads as actively "in progress"
190+ // at a glance (alongside the chronometer), matching the conventional run affordance.
191+ .setProgress(0, 0, true)
192+ // Add a Stop action so the user can cancel the run without opening the app — the
193+ // core of the "kick it off and walk away" flow. Only when a specific session is
194+ // known (multiple concurrent runs can't be targeted individually). The profile id
195+ // is embedded so [AppContainer.abortRunFromNotification]'s routing guard sends the
196+ // abort to the originating server even if the user has switched profiles between
197+ // starting the run and tapping Stop (mirroring the other notification actions).
198+ if (sessionId != null) {
199+ val stopIntent = Intent(this, NotificationActionReceiver::class.java).apply {
200+ action = NotificationActionReceiver.ACTION_STOP_RUN
201+ putExtra(NotificationActionReceiver.EXTRA_SESSION_ID, sessionId)
202+ profileId?.let { putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, it) }
203+ }
204+ // Derive the request code from a SHA-256 of the session id (truncated to 31 bits),
205+ // NOT String.hashCode(): two distinct session ids can share a 32-bit hashCode, and
206+ // with FLAG_UPDATE_CURRENT the second registration overwrites the first's extras —
207+ // so a hash collision between two concurrent runs would make tapping Stop on one
208+ // session's notification broadcast the OTHER session's id. Mirrors SessionNotifications.
209+ // notifId's collision-resistant derivation.
210+ val requestCode = stopRequestCode(sessionId)
211+ val stopPending = PendingIntent.getBroadcast(
212+ this, requestCode, stopIntent,
213+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
214+ )
215+ builder.addAction(
216+ R.drawable.ic_action_stop,
217+ getString(R.string.notif_action_stop),
218+ stopPending,
219+ )
220+ }
221+ return builder.build()
222+ }
223+
224+ // Android 14+ can time out a foreground service (notably the ~6h/day cumulative cap on
225+ // dataSync in Android 15) and calls onTimeout expecting a prompt stop; not stopping risks
226+ // the system force-stopping/ANR-ing the app. Stop cleanly — a still-active run just
227+ // continues without foreground priority. Post a low-priority notification so the user
228+ // knows their long-running task may be paused in the background, rather than silently
229+ // losing foreground priority. Both overloads are covered (the 2-arg form is API 35+).
230+ override fun onTimeout(startId: Int) {
231+ Log.w(TAG, "Foreground service timed out; stopping")
232+ postTimeoutNotification()
233+ stopSelf()
234+ }
235+
236+ override fun onTimeout(startId: Int, fgsType: Int) {
237+ Log.w(TAG, "Foreground service timed out (type=$fgsType); stopping")
238+ postTimeoutNotification()
239+ stopSelf()
240+ }
241+
242+ /** Post a low-priority notification informing the user that the foreground service was
243+ * timed out by the system — the run continues but may be killed by Doze without warning. */
244+ @SuppressLint("MissingPermission")
245+ private fun postTimeoutNotification() {
246+ runCatching {
247+ val notification = NotificationCompat.Builder(this, NotificationChannels.STATUS)
248+ .setSmallIcon(R.drawable.ic_stat_notify)
249+ .setColor(brandColor(this))
250+ .setContentTitle(getString(R.string.notif_running_title))
251+ .setContentText(getString(R.string.notif_fg_timeout_text))
252+ .setContentIntent(mainContentIntent())
253+ .setAutoCancel(true)
254+ .setPriority(NotificationCompat.PRIORITY_LOW)
255+ .build()
256+ NotificationManagerCompat.from(this).notify(NOTIF_TIMEOUT_ID, notification)
257+ }
258+ }
259+
260+ override fun onDestroy() {
261+ // Safety net: if the process is being killed while a run is in progress,
262+ // ensure the notification is removed rather than lingering.
263+ ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
264+ // Clear the run-start / foreground / instance state so the next run's chronometer
265+ // begins fresh and startForeground is re-asserted on the first intent of the next
266+ // run. These are instance fields, so a recreated service resets them by construction
267+ // — but clearing here also covers the same-instance restart case.
268+ runStartMillis = 0L
269+ isInForeground = false
270+ lastTitle = null
271+ lastSessionId = null
272+ lastProfileId = null
273+ lastProgress = null
274+ instance = null
275+ super.onDestroy()
276+ }
277+
278+ override fun onBind(intent: Intent?): IBinder? = null
279+
280+ // Reset the brand-color cache when the configuration (notably uiMode/night mode) changes
281+ // so the notification icon tint follows the app theme instead of staying pinned to whatever
282+ // the first build resolved. The service isn't recreated on a config change (it's a service,
283+ // not an Activity), so without this the cached color is stale until onDestroy.
284+ override fun onConfigurationChanged(newConfig: Configuration) {
285+ super.onConfigurationChanged(newConfig)
286+ brandColorCache = 0
287+ }
288+
289+ companion object {
290+ private const val TAG = "RunForegroundService"
291+ private const val NOTIF_ID = 1
292+ private const val NOTIF_TIMEOUT_ID = 2
293+
294+ /** Derive a stable, collision-resistant PendingIntent request code from a session id.
295+ * String.hashCode() can collide for distinct session ids; a SHA-256 truncated to 31
296+ * bits makes accidental collisions astronomically unlikely, so two concurrent runs'
297+ * Stop actions can't overwrite each other's PendingIntent extras. Mirrors
298+ * [SessionNotifications.notifId]'s derivation. */
299+ private fun stopRequestCode(sessionId: String): Int {
300+ val digest = runCatching { MessageDigest.getInstance("SHA-256") }
301+ .getOrNull()?.digest(sessionId.toByteArray()) ?: return sessionId.hashCode()
302+ val hash = (((digest[0].toInt() and 0xFF) shl 24) or
303+ ((digest[1].toInt() and 0xFF) shl 16) or
304+ ((digest[2].toInt() and 0xFF) shl 8) or
305+ (digest[3].toInt() and 0xFF)).and(0x7FFFFFFF)
306+ return hash
307+ }
308+ // Brand accent for the foreground notifications' small-icon tint circle. Resolved
309+ // from resources (R.color.notif_brand) so it follows the app theme (light/dark)
310+ // instead of a hardcoded constant. Lazily computed on first use from the app context
311+ // and reset in onConfigurationChanged so a theme toggle mid-run doesn't pin the tint
312+ // to the old theme for the rest of the run.
313+ private var brandColorCache: Int = 0
314+ private fun brandColor(context: Context): Int {
315+ if (brandColorCache == 0) {
316+ brandColorCache = androidx.core.content.ContextCompat.getColor(context, R.color.notif_brand)
317+ }
318+ return brandColorCache
319+ }
320+ // Live reference to the running service instance, published in onStartCommand and
321+ // cleared in onDestroy. @Volatile so [updateProgress]'s read from the app-scope
322+ // coroutine sees the most recent publication. Null when the service isn't running.
323+ @Volatile private var instance: RunForegroundService? = null
324+ const val EXTRA_SESSION_TITLE = "soy.iko.opencode.extra.SESSION_TITLE"
325+ const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
326+ const val EXTRA_PROFILE_ID = "soy.iko.opencode.extra.PROFILE_ID"
327+ const val EXTRA_PROGRESS = "soy.iko.opencode.extra.PROGRESS"
328+
329+ /** Start the service (or refresh an already-running one). [profileId] is embedded in the
330+ * Stop action's PendingIntent so an abort from the notification reaches the originating
331+ * server even after a profile switch. */
332+ fun start(
333+ context: Context,
334+ sessionTitle: String? = null,
335+ sessionId: String? = null,
336+ profileId: String? = null,
337+ progress: String? = null,
338+ ) {
339+ // If the service is already in the foreground, refresh the notification in place
340+ // via NotificationManager.notify (through the live instance) instead of going
341+ // through startForegroundService. On Android 12+, startForegroundService can
342+ // throw ForegroundServiceStartNotAllowedException when the app is backgrounded
343+ // — *even if the service is already running in the foreground* — and the
344+ // runCatching below would swallow it, leaving the progress text un-updated
345+ // precisely when the app is backgrounded (the FGS's whole purpose). The in-place
346+ // notify never throws, so progress refreshes always land while the service runs.
347+ val live = instance
348+ if (live != null) {
349+ // Forward the full set of run fields (not just progress) so a late-resolved
350+ // session title or a second run updates the notification text AND the Stop
351+ // action's target session/profile — otherwise the Stop action would keep
352+ // targeting the originally-captured session even after a profile switch.
353+ live.updateProgress(progress, sessionTitle, sessionId, profileId)
354+ return
355+ }
356+ // startForegroundService can throw ForegroundServiceStartNotAllowedException
357+ // on Android 12+ if the app is in the background. Wrap it so a backgrounded
358+ // start (e.g. the user navigates away at the wrong moment) doesn't crash.
359+ runCatching {
360+ val intent = Intent(context, RunForegroundService::class.java)
361+ sessionTitle?.let { intent.putExtra(EXTRA_SESSION_TITLE, it) }
362+ sessionId?.let { intent.putExtra(EXTRA_SESSION_ID, it) }
363+ profileId?.let { intent.putExtra(EXTRA_PROFILE_ID, it) }
364+ progress?.let { intent.putExtra(EXTRA_PROGRESS, it) }
365+ context.startForegroundService(intent)
366+ }.onFailure {
367+ Log.w(TAG, "startForegroundService failed; running without foreground priority", it)
368+ }
369+ }
370+
371+ fun stop(context: Context) {
372+ context.stopService(Intent(context, RunForegroundService::class.java))
373+ }
374+ }
375+}
app/src/main/java/soy/iko/opencode/notification/SessionNotifications.ktnew file+564
@@ -0,0 +1,564 @@
1+package soy.iko.opencode.notification
2+
3+import android.annotation.SuppressLint
4+import android.app.Notification
5+import android.app.NotificationManager
6+import android.app.PendingIntent
7+import android.content.Context
8+import android.content.Intent
9+import android.os.Build
10+import android.util.Log
11+import androidx.core.app.NotificationCompat
12+import androidx.core.app.NotificationManagerCompat
13+import androidx.core.app.RemoteInput
14+import soy.iko.opencode.MainActivity
15+import soy.iko.opencode.R
16+import soy.iko.opencode.data.model.Permission
17+import soy.iko.opencode.data.model.PermissionResponse
18+import java.security.MessageDigest
19+
20+/**
21+ * Builds and posts the app's session notifications:
22+ *
23+ * - [postCompleted]: a "session ready" notification when a background run finishes, with an
24+ * inline [RemoteInput] Reply action so a follow-up can be sent without opening the app.
25+ * - [postPermission]: a heads-up notification when a paused tool needs approval, with
26+ * Allow once / Always / Reject action buttons handled by [NotificationActionReceiver].
27+ * - [postError]: a notification when a background run fails.
28+ *
29+ * Tapping a notification body opens the session via [MainActivity] with an
30+ * [MainActivity.EXTRA_SESSION_ID] extra. Notification posting is guarded for Android 13+
31+ * runtime permission: if the user hasn't granted POST_NOTIFICATIONS, the post is silently
32+ * skipped rather than throwing.
33+ */
34+object SessionNotifications {
35+
36+ private const val NOTIF_ID_PREFIX = 4000
37+ private const val TAG = "SessionNotifications"
38+ // Fixed notification IDs use NEGATIVE space (mirroring the SUMMARY_*_ID constants below)
39+ // so they can never collide with the hash-derived per-session IDs returned by notifId(),
40+ // which are always positive (NOTIF_ID_PREFIX + (hash % (0x7FFFFFFF - NOTIF_ID_PREFIX)),
41+ // range [4000, 2147483646]). A collision would let a session-specific notification
42+ // overwrite or be canceled by the fixed one (~1 in 2 billion per session, but a real
43+ // ID-space flaw). Negative IDs are valid for NotificationManager.cancel/notify.
44+ private const val UNREAD_BADGE_ID = -10
45+ private const val CONNECTION_LOST_ID = -11
46+
47+ // Distinct namespaces so a session can have a completion, a permission, and an error
48+ // notification outstanding at once without their ids colliding.
49+ private const val NS_COMPLETED = "done"
50+ private const val NS_PERMISSION = "perm"
51+ private const val NS_ERROR = "err"
52+
53+ private val notifIdRegex = Regex("[^A-Za-z0-9_-]")
54+
55+ /** Derive a stable, collision-resistant notification id from a namespace + session id.
56+ * String.hashCode() can collide for different inputs; a SHA-256 truncated to 31
57+ * bits makes accidental collisions astronomically unlikely. The namespace keeps the
58+ * three notification kinds for one session in separate id spaces. */
59+ private fun notifId(namespace: String, sessionId: String): Int {
60+ val digest = runCatching { MessageDigest.getInstance("SHA-256") }
61+ .getOrNull()?.digest("$namespace:$sessionId".toByteArray()) ?: return NOTIF_ID_PREFIX
62+ // Take the first 4 bytes, mask to 31 bits (always positive) to fit an Int id.
63+ // Kotlin's shl/or/and are equal-precedence infix functions evaluated strictly
64+ // left-to-right, so the shifts MUST be parenthesized individually — otherwise
65+ // `a shl 24 or b shl 16` parses as `((a shl 24) or b) shl 16`, shifting the first
66+ // byte out of the Int entirely and collapsing the id's entropy.
67+ val hash = (((digest[0].toInt() and 0xFF) shl 24) or
68+ ((digest[1].toInt() and 0xFF) shl 16) or
69+ ((digest[2].toInt() and 0xFF) shl 8) or
70+ (digest[3].toInt() and 0xFF)).and(0x7FFFFFFF)
71+ // Offset by NOTIF_ID_PREFIX, staying within the positive Int range.
72+ // Using modulo ensures the prefix is preserved without Int overflow.
73+ return NOTIF_ID_PREFIX + (hash % (0x7FFFFFFF - NOTIF_ID_PREFIX))
74+ }
75+
76+ /** Respect the POST_NOTIFICATIONS runtime permission (Android 13+): if the user denied
77+ * it, callers should skip posting instead of throwing. Foreground-service notifications
78+ * are exempt, so the running indicator still shows during a run. */
79+ private fun canPost(context: Context): Boolean =
80+ Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
81+ androidx.core.content.ContextCompat.checkSelfPermission(
82+ context, android.Manifest.permission.POST_NOTIFICATIONS,
83+ ) == android.content.pm.PackageManager.PERMISSION_GRANTED
84+
85+ /** A PendingIntent that opens [sessionId] in the app when the notification body is tapped.
86+ * [profileId] (the originating server) is embedded so a tap after the user has switched
87+ * servers routes back to the server that ran the session, not whichever is active —
88+ * mirroring the Reply and permission action intents. */
89+ private fun openSessionIntent(
90+ context: Context, sessionId: String, requestCode: Int, profileId: String?,
91+ ): PendingIntent {
92+ // Sanitize the session id before embedding it in the deep-link URI so characters like
93+ // /, ?, # can't inject path segments or query parameters.
94+ val safeId = notifIdRegex.replace(sessionId, "")
95+ val openIntent = Intent(context, MainActivity::class.java).apply {
96+ action = Intent.ACTION_VIEW
97+ data = android.net.Uri.parse("opencode://session/$safeId")
98+ flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
99+ profileId?.let { putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, it) }
100+ }
101+ return PendingIntent.getActivity(
102+ context, requestCode, openIntent,
103+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
104+ )
105+ }
106+
107+ // canPost() checks POST_NOTIFICATIONS before every notify(); lint can't follow the
108+ // check through the helper, so suppress its MissingPermission flag on these posts.
109+ @SuppressLint("MissingPermission")
110+ fun postCompleted(context: Context, sessionId: String, title: String, profileId: String?) {
111+ if (!canPost(context)) return
112+ val notifId = notifId(NS_COMPLETED, sessionId)
113+
114+ // Inline reply: a RemoteInput-backed action that broadcasts the typed follow-up to
115+ // NotificationActionReceiver. The PendingIntent MUST be mutable so the system can
116+ // fill in the RemoteInput results before delivering it. [profileId] is embedded so the
117+ // receiver enqueues the reply against the server that ran the session, not whichever
118+ // server is active when the user taps (mirrors postPermission).
119+ val remoteInput = RemoteInput.Builder(NotificationActionReceiver.KEY_REPLY_TEXT)
120+ .setLabel(context.getString(R.string.notif_reply_hint))
121+ .build()
122+ val replyIntent = Intent(context, NotificationActionReceiver::class.java).apply {
123+ action = NotificationActionReceiver.ACTION_REPLY
124+ putExtra(NotificationActionReceiver.EXTRA_SESSION_ID, sessionId)
125+ profileId?.let { putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, it) }
126+ }
127+ val replyPending = PendingIntent.getBroadcast(
128+ context, notifId, replyIntent,
129+ PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag(),
130+ )
131+ val replyAction = NotificationCompat.Action.Builder(
132+ R.drawable.ic_action_reply,
133+ context.getString(R.string.notif_action_reply),
134+ replyPending,
135+ ).addRemoteInput(remoteInput).setAllowGeneratedReplies(true).build()
136+
137+ // "Mark read" action so the user can triage a completion without opening the session.
138+ val markReadIntent = Intent(context, NotificationActionReceiver::class.java).apply {
139+ action = NotificationActionReceiver.ACTION_MARK_READ
140+ putExtra(NotificationActionReceiver.EXTRA_SESSION_ID, sessionId)
141+ profileId?.let { putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, it) }
142+ }
143+ val markReadPending = PendingIntent.getBroadcast(
144+ context, notifId + 100, markReadIntent,
145+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
146+ )
147+ val markReadAction = NotificationCompat.Action.Builder(
148+ R.drawable.ic_action_check,
149+ context.getString(R.string.notif_action_mark_read),
150+ markReadPending,
151+ ).build()
152+
153+ val notification = NotificationCompat.Builder(context, NotificationChannels.COMPLETED)
154+ .setSmallIcon(R.drawable.ic_stat_notify)
155+ .setContentTitle(context.getString(R.string.notif_completed_title))
156+ // No % escaping here: getString(id, arg) inserts the argument verbatim and
157+ // never re-scans it for format specifiers, so escaping the title would show
158+ // literal doubled percent signs (e.g. a "50% done" title as "50%% done").
159+ .setContentText(context.getString(R.string.notif_completed_text, title))
160+ .setAutoCancel(true)
161+ .setOnlyAlertOnce(true)
162+ .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
163+ .addAction(replyAction)
164+ .addAction(markReadAction)
165+ .setCategory(NotificationCompat.CATEGORY_MESSAGE)
166+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
167+ // Group notifications from the same server so multiple completions collapse into
168+ // a stackable group on Android 7+ instead of filling the shade with individual entries.
169+ .setGroup(GROUP_COMPLETED)
170+ .build()
171+
172+ runCatching {
173+ NotificationManagerCompat.from(context).notify(notifId, notification)
174+ // Post a summary notification so the group has a single top-level entry in the shade.
175+ postCompletedSummary(context)
176+ }.onFailure { Log.w(TAG, "Failed to post completion notification", it) }
177+ }
178+
179+ /** Post (or update) a summary notification for the completed-sessions group so multiple
180+ * completions collapse into one stackable entry instead of filling the shade. */
181+ @SuppressLint("MissingPermission")
182+ private fun postCompletedSummary(context: Context) {
183+ // The summary needs a content intent so tapping it opens the app (and so
184+ // setAutoCancel can fire on tap — without a PendingIntent the tap does nothing
185+ // and the summary can only be dismissed by swiping). A plain open-app intent is
186+ // right for a group summary: there's no single session to route to.
187+ val openIntent = Intent(context, MainActivity::class.java).apply {
188+ flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
189+ }
190+ val contentIntent = PendingIntent.getActivity(
191+ context, 0, openIntent,
192+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
193+ )
194+ val summary = NotificationCompat.Builder(context, NotificationChannels.COMPLETED)
195+ .setSmallIcon(R.drawable.ic_stat_notify)
196+ .setContentTitle(context.getString(R.string.notif_completed_title))
197+ .setContentText(context.getString(R.string.app_name))
198+ .setContentIntent(contentIntent)
199+ .setAutoCancel(true)
200+ .setCategory(NotificationCompat.CATEGORY_MESSAGE)
201+ .setGroup(GROUP_COMPLETED)
202+ .setGroupSummary(true)
203+ .build()
204+ runCatching {
205+ NotificationManagerCompat.from(context).notify(SUMMARY_COMPLETED_ID, summary)
206+ }.onFailure { Log.w(TAG, "Failed to post completion summary notification", it) }
207+ }
208+
209+ /** Post a heads-up notification for a permission request with Allow once / Always /
210+ * Reject action buttons. [sessionTitle] labels the notification; [permission]'s title/
211+ * pattern/type supplies the detail line. [profileId] is embedded in the action intents
212+ * so the receiver routes the response back to the server that posted the request, not
213+ * whichever server happens to be active when the user taps. */
214+ @SuppressLint("MissingPermission")
215+ fun postPermission(context: Context, permission: Permission, sessionTitle: String, profileId: String?) {
216+ if (!canPost(context)) return
217+ val sessionId = permission.sessionID.takeIf { it.isNotBlank() } ?: return
218+ val notifId = notifId(NS_PERMISSION, sessionId)
219+ val detail = permission.title?.takeIf { it.isNotBlank() }
220+ ?: permission.patternText?.takeIf { it.isNotBlank() }
221+ ?: permission.type?.takeIf { it.isNotBlank() }
222+ ?: context.getString(R.string.notif_permission_fallback)
223+
224+ val builder = NotificationCompat.Builder(context, NotificationChannels.PERMISSION)
225+ .setSmallIcon(R.drawable.ic_stat_notify)
226+ .setContentTitle(context.getString(R.string.notif_permission_title, sessionTitle))
227+ .setContentText(detail)
228+ .setStyle(NotificationCompat.BigTextStyle().bigText(detail))
229+ .setAutoCancel(true)
230+ .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
231+ .setPriority(NotificationCompat.PRIORITY_HIGH)
232+ .setGroup(GROUP_PERMISSION)
233+ .setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
234+ .setPublicVersion(
235+ NotificationCompat.Builder(context, NotificationChannels.PERMISSION)
236+ .setSmallIcon(R.drawable.ic_stat_notify)
237+ .setContentTitle(context.getString(R.string.notif_permission_public))
238+ .build(),
239+ )
240+
241+ // One action per response. Each PendingIntent needs a distinct request code or
242+ // FLAG_UPDATE_CURRENT would collapse them into one (the last extras win).
243+ listOf(
244+ Triple(PermissionResponse.ONCE, R.string.notif_action_allow_once, R.drawable.ic_action_check),
245+ Triple(PermissionResponse.ALWAYS, R.string.notif_action_always, R.drawable.ic_action_check),
246+ Triple(PermissionResponse.REJECT, R.string.notif_action_reject, R.drawable.ic_action_block),
247+ ).forEach { (response, labelRes, iconRes) ->
248+ val intent = Intent(context, NotificationActionReceiver::class.java).apply {
249+ action = NotificationActionReceiver.ACTION_PERMISSION
250+ putExtra(NotificationActionReceiver.EXTRA_SESSION_ID, sessionId)
251+ putExtra(NotificationActionReceiver.EXTRA_PERMISSION_ID, permission.id)
252+ putExtra(NotificationActionReceiver.EXTRA_RESPONSE, response.wire)
253+ profileId?.let { putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, it) }
254+ }
255+ val pending = PendingIntent.getBroadcast(
256+ context, notifId + response.ordinal + 1, intent,
257+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
258+ )
259+ builder.addAction(iconRes, context.getString(labelRes), pending)
260+ }
261+
262+ runCatching { NotificationManagerCompat.from(context).notify(notifId, builder.build()) }
263+ .onFailure { Log.w(TAG, "Failed to post permission notification", it) }
264+ // Group permission notifications so a multi-session burst collapses into a stackable
265+ // group instead of filling the shade — mirroring the completed-sessions grouping.
266+ runCatching { postGroupSummary(context, NotificationChannels.PERMISSION, GROUP_PERMISSION, SUMMARY_PERMISSION_ID) }
267+ .onFailure { Log.w(TAG, "Failed to post permission summary notification", it) }
268+ }
269+
270+ /** Post a notification when a permission prompt auto-rejected on timeout. A user who
271+ * walked away from an in-app prompt returns to find the run stopped with no on-screen
272+ * record of why; this leaves a persistent trace in the shade explaining the stop and
273+ * offering to re-open the session. Uses the permission channel (heads-up) so it's seen. */
274+ @SuppressLint("MissingPermission")
275+ fun postPermissionAutoRejected(context: Context, sessionId: String, sessionTitle: String, profileId: String?) {
276+ if (!canPost(context)) return
277+ val notifId = notifId(NS_PERMISSION, sessionId) + 1 // distinct from the live prompt
278+ val detail = context.getString(R.string.notif_permission_auto_rejected_text)
279+ val builder = NotificationCompat.Builder(context, NotificationChannels.PERMISSION)
280+ .setSmallIcon(R.drawable.ic_stat_notify)
281+ .setContentTitle(context.getString(R.string.notif_permission_auto_rejected_title, sessionTitle))
282+ .setContentText(detail)
283+ .setStyle(NotificationCompat.BigTextStyle().bigText(detail))
284+ .setAutoCancel(true)
285+ .setOnlyAlertOnce(true)
286+ .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
287+ .setCategory(NotificationCompat.CATEGORY_STATUS)
288+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
289+ .setGroup(GROUP_PERMISSION)
290+ .setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
291+ runCatching { NotificationManagerCompat.from(context).notify(notifId, builder.build()) }
292+ .onFailure { Log.w(TAG, "Failed to post permission auto-reject notification", it) }
293+ runCatching { postGroupSummary(context, NotificationChannels.PERMISSION, GROUP_PERMISSION, SUMMARY_PERMISSION_ID) }
294+ .onFailure { Log.w(TAG, "Failed to post permission summary notification", it) }
295+ }
296+
297+ /** Post a notification when a background run fails. */
298+ @SuppressLint("MissingPermission")
299+ fun postError(context: Context, sessionId: String, title: String, profileId: String? = null) {
300+ if (!canPost(context)) return
301+ val notifId = notifId(NS_ERROR, sessionId)
302+ val builder = NotificationCompat.Builder(context, NotificationChannels.ERROR)
303+ .setSmallIcon(R.drawable.ic_stat_notify)
304+ .setContentTitle(context.getString(R.string.notif_error_title))
305+ .setContentText(context.getString(R.string.notif_error_text, title))
306+ .setAutoCancel(true)
307+ .setOnlyAlertOnce(true)
308+ .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
309+ .setCategory(NotificationCompat.CATEGORY_ERROR)
310+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
311+ // Group error notifications so a burst of failures collapses into a stackable group.
312+ .setGroup(GROUP_ERROR)
313+ .setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
314+ .setPublicVersion(
315+ NotificationCompat.Builder(context, NotificationChannels.ERROR)
316+ .setSmallIcon(R.drawable.ic_stat_notify)
317+ .setContentTitle(context.getString(R.string.notif_error_public))
318+ .build(),
319+ )
320+ // A "Retry last" action lets the user re-run the failed prompt straight from the shade
321+ // without opening the app — a failed run is exactly when a one-tap retry is most useful.
322+ val retryIntent = Intent(context, NotificationActionReceiver::class.java).apply {
323+ action = NotificationActionReceiver.ACTION_RETRY_LAST
324+ putExtra(NotificationActionReceiver.EXTRA_SESSION_ID, sessionId)
325+ profileId?.let { putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, it) }
326+ }
327+ val retryPending = PendingIntent.getBroadcast(
328+ context, notifId + 10, retryIntent,
329+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
330+ )
331+ builder.addAction(R.drawable.ic_action_refresh, context.getString(R.string.retry_last), retryPending)
332+ val notification = builder.build()
333+ runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
334+ .onFailure { Log.w(TAG, "Failed to post error notification", it) }
335+ runCatching { postGroupSummary(context, NotificationChannels.ERROR, GROUP_ERROR, SUMMARY_ERROR_ID) }
336+ .onFailure { Log.w(TAG, "Failed to post error summary notification", it) }
337+ }
338+
339+ /** Update the completion notification to show a brief "Sent" confirmation after a
340+ * successful inline reply, replacing the completion notification's content. Auto-cancels
341+ * on tap (via a [setContentIntent]) so it doesn't linger. */
342+ @SuppressLint("MissingPermission")
343+ fun postReplySent(context: Context, sessionId: String, profileId: String? = null) {
344+ if (!canPost(context)) {
345+ cancel(context, sessionId)
346+ return
347+ }
348+ val notifId = notifId(NS_COMPLETED, sessionId)
349+ val notification = NotificationCompat.Builder(context, NotificationChannels.COMPLETED)
350+ .setSmallIcon(R.drawable.ic_stat_notify)
351+ .setContentTitle(context.getString(R.string.notif_reply_sent))
352+ // setAutoCancel(true) only fires when the user taps a contentIntent; without one
353+ // the "Sent" notification lingers indefinitely (the prior behavior). Wire the
354+ // open-session intent so a tap dismisses the notification and opens the session.
355+ .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
356+ .setAutoCancel(true)
357+ .setPriority(NotificationCompat.PRIORITY_LOW)
358+ .setGroup(GROUP_COMPLETED)
359+ .build()
360+ runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
361+ .onFailure { Log.w(TAG, "Failed to post reply-sent notification", it) }
362+ }
363+
364+ /** An inline reply couldn't be durably enqueued (no resolvable profile, or the outbox write
365+ * threw). Cancel the completion notification so SystemUI's RemoteInput field doesn't stay
366+ * stuck in the "sending…" spinner — a stuck field can't be resubmitted — and post an error
367+ * notification so the reply isn't silently lost and the user can reopen the session to retry. */
368+ @SuppressLint("MissingPermission")
369+ fun postReplyFailed(context: Context, sessionId: String, profileId: String? = null) {
370+ NotificationManagerCompat.from(context).cancel(notifId(NS_COMPLETED, sessionId))
371+ maybeCancelSummary(context, GROUP_COMPLETED, SUMMARY_COMPLETED_ID)
372+ if (!canPost(context)) return
373+ val notifId = notifId(NS_ERROR, sessionId)
374+ val notification = NotificationCompat.Builder(context, NotificationChannels.ERROR)
375+ .setSmallIcon(R.drawable.ic_stat_notify)
376+ .setContentTitle(context.getString(R.string.notif_reply_failed_title))
377+ .setContentText(context.getString(R.string.notif_reply_failed_text))
378+ .setAutoCancel(true)
379+ .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
380+ .setCategory(NotificationCompat.CATEGORY_ERROR)
381+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
382+ // Group with the other error notifications (mirrors postError/postOutboxDropped)
383+ // so this notification joins the GROUP_ERROR stack and the group summary stays
384+ // consistent. Without .setGroup, reusing the NS_ERROR id space would overwrite a
385+ // prior postError for this session without the group association, leaving an
386+ // orphaned SUMMARY_ERROR_ID group summary with no children lingering in the shade.
387+ .setGroup(GROUP_ERROR)
388+ .build()
389+ runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
390+ .onFailure { Log.w(TAG, "Failed to post reply-failed notification", it) }
391+ runCatching { postGroupSummary(context, NotificationChannels.ERROR, GROUP_ERROR, SUMMARY_ERROR_ID) }
392+ .onFailure { Log.w(TAG, "Failed to post error summary notification", it) }
393+ }
394+
395+ /** A queued outbox message was permanently undeliverable (e.g. the target session was
396+ * deleted server-side → 404). Distinct from [postError] (which is titled "Run failed"):
397+ * no run failed — a queued reply couldn't be delivered. Uses a distinct title/body so the
398+ * user understands the failure mode and recovery action (open the session and re-send). */
399+ @SuppressLint("MissingPermission")
400+ fun postOutboxDropped(context: Context, sessionId: String, sessionTitle: String, profileId: String? = null) {
401+ if (!canPost(context)) return
402+ val notifId = notifId(NS_ERROR, sessionId)
403+ val notification = NotificationCompat.Builder(context, NotificationChannels.ERROR)
404+ .setSmallIcon(R.drawable.ic_stat_notify)
405+ .setContentTitle(context.getString(R.string.message_not_delivered))
406+ .setContentText(context.getString(R.string.message_not_delivered_body, sessionTitle))
407+ .setAutoCancel(true)
408+ .setOnlyAlertOnce(true)
409+ .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
410+ .setCategory(NotificationCompat.CATEGORY_ERROR)
411+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
412+ .setGroup(GROUP_ERROR)
413+ .build()
414+ runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
415+ .onFailure { Log.w(TAG, "Failed to post outbox-dropped notification", it) }
416+ runCatching { postGroupSummary(context, NotificationChannels.ERROR, GROUP_ERROR, SUMMARY_ERROR_ID) }
417+ .onFailure { Log.w(TAG, "Failed to post error summary notification", it) }
418+ }
419+
420+ /** The SSE stream hit a non-retryable failure (Failed/AuthFailed) while the app was
421+ * backgrounded during an active run, so the in-app banner isn't visible and the run is
422+ * effectively stranded. Posts a low-priority notification so a backgrounded user is
423+ * signaled that their run is stalled and can tap to reconnect. Distinct from [postError]
424+ * (a run error) — this is a connection loss, not a run failure. */
425+ @SuppressLint("MissingPermission")
426+ fun postConnectionLost(context: Context, profileLabel: String, profileId: String?) {
427+ if (!canPost(context)) return
428+ val notifId = CONNECTION_LOST_ID
429+ // Tap reopens the app at the server list so the user can reconnect / fix credentials.
430+ val openIntent = Intent(context, MainActivity::class.java).apply {
431+ flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
432+ profileId?.let { putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, it) }
433+ }
434+ val openPending = PendingIntent.getActivity(
435+ context, notifId, openIntent,
436+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
437+ )
438+ val notification = NotificationCompat.Builder(context, NotificationChannels.ERROR)
439+ .setSmallIcon(R.drawable.ic_stat_notify)
440+ .setContentTitle(context.getString(R.string.connection_lost, profileLabel))
441+ .setContentText(context.getString(R.string.connection_lost_tap))
442+ .setAutoCancel(true)
443+ .setOnlyAlertOnce(true)
444+ .setContentIntent(openPending)
445+ .setCategory(NotificationCompat.CATEGORY_STATUS)
446+ .setPriority(NotificationCompat.PRIORITY_LOW)
447+ .build()
448+ runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
449+ .onFailure { Log.w(TAG, "Failed to post connection-lost notification", it) }
450+ }
451+
452+ /** Cancel a previously-posted [postConnectionLost] notification (e.g. on reconnect). */
453+ fun cancelConnectionLost(context: Context) {
454+ NotificationManagerCompat.from(context).cancel(CONNECTION_LOST_ID)
455+ }
456+
457+ /** FLAG_MUTABLE where required (Android 12+) so RemoteInput results can be injected. */
458+ private fun mutableFlag(): Int =
459+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0
460+
461+ /** Cancel a session's completion notification (e.g. when the user opens it). */
462+ fun cancel(context: Context, sessionId: String) {
463+ NotificationManagerCompat.from(context).cancel(notifId(NS_COMPLETED, sessionId))
464+ maybeCancelSummary(context, GROUP_COMPLETED, SUMMARY_COMPLETED_ID)
465+ }
466+
467+ /**
468+ * Reflect the total number of unread sessions as a launcher-icon badge. Android only badges
469+ * the launcher icon via a notification on a badge-enabled channel, so this posts a silent,
470+ * min-importance notification with [.setNumber] on the [NotificationChannels.UNREAD] channel
471+ * when [totalUnread] > 0, and cancels it when it drops to 0. Skipped entirely without
472+ * POST_NOTIFICATIONS permission (Android 13+) — the badge just won't show, no error.
473+ */
474+ @SuppressLint("MissingPermission")
475+ fun updateUnreadBadge(context: Context, totalUnread: Int) {
476+ val nm = NotificationManagerCompat.from(context)
477+ if (totalUnread <= 0) {
478+ nm.cancel(UNREAD_BADGE_ID)
479+ return
480+ }
481+ if (!canPost(context)) return
482+ val notification = NotificationCompat.Builder(context, NotificationChannels.UNREAD)
483+ .setSmallIcon(R.drawable.ic_stat_notify)
484+ .setContentTitle(context.getString(R.string.app_name))
485+ .setContentText(context.resources.getQuantityString(R.plurals.unread_sessions, totalUnread, totalUnread))
486+ .setNumber(totalUnread.coerceAtMost(999))
487+ .setOnlyAlertOnce(true)
488+ .setSilent(true)
489+ .setShowWhen(false)
490+ .setPriority(NotificationCompat.PRIORITY_MIN)
491+ .build()
492+ runCatching { nm.notify(UNREAD_BADGE_ID, notification) }
493+ .onFailure { Log.w(TAG, "Failed to post unread badge notification", it) }
494+ }
495+
496+ /** Cancel a session's permission notification (on reply, or when the user opens it). Also
497+ * cancels the auto-rejected-permission notification (posted under notifId + 1) so opening
498+ * the session by any route other than tapping that notification clears it too — without
499+ * this a stale "auto-rejected" heads-up lingers in the shade (setAutoCancel only dismisses
500+ * on a direct tap). */
501+ fun cancelPermission(context: Context, sessionId: String) {
502+ val nm = NotificationManagerCompat.from(context)
503+ val baseId = notifId(NS_PERMISSION, sessionId)
504+ nm.cancel(baseId)
505+ nm.cancel(baseId + 1) // postPermissionAutoRejected uses baseId + 1
506+ maybeCancelSummary(context, GROUP_PERMISSION, SUMMARY_PERMISSION_ID)
507+ }
508+
509+ /** Cancel a session's error notification (e.g. when the user opens it). Without this a
510+ * stale "run failed" notification lingers when the session is opened by any route other
511+ * than tapping the notification itself (setAutoCancel only clears it on a direct tap). */
512+ fun cancelError(context: Context, sessionId: String) {
513+ NotificationManagerCompat.from(context).cancel(notifId(NS_ERROR, sessionId))
514+ maybeCancelSummary(context, GROUP_ERROR, SUMMARY_ERROR_ID)
515+ }
516+
517+ /** Build the lock-screen (public) version of a notification: only a generic title, no
518+ * detail line, so sensitive file paths / commands in the real content aren't revealed
519+ * while the device is locked. */
520+ /** When the last child of a group is dismissed, cancel its lingering summary so it
521+ * doesn't sit empty in the shade. NotificationManagerCompat lacks getActiveNotifications,
522+ * so reach for the platform NotificationManager. The children/summary are posted without
523+ * a tag, so matching by [android.app.Notification.getGroup] (plus excluding the summary
524+ * id) is the reliable way to tell if any child remains. */
525+ private fun maybeCancelSummary(context: Context, group: String, summaryId: Int) {
526+ val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
527+ ?: return
528+ val active = try {
529+ nm.activeNotifications
530+ } catch (e: Exception) {
531+ Log.w(TAG, "Failed to query active notifications for summary cleanup", e)
532+ return
533+ }
534+ val hasChild = active.any { it.id != summaryId && it.notification?.group == group }
535+ if (!hasChild) {
536+ runCatching { NotificationManagerCompat.from(context).cancel(summaryId) }
537+ .onFailure { Log.w(TAG, "Failed to cancel empty group summary", it) }
538+ }
539+ }
540+
541+ private const val GROUP_COMPLETED = "soy.iko.opencode.COMPLETED"
542+ private const val GROUP_PERMISSION = "soy.iko.opencode.PERMISSION"
543+ private const val GROUP_ERROR = "soy.iko.opencode.ERROR"
544+ private const val SUMMARY_COMPLETED_ID = -1
545+ private const val SUMMARY_PERMISSION_ID = -2
546+ private const val SUMMARY_ERROR_ID = -3
547+
548+ /** Post a summary notification for a group so multiple entries collapse into one stackable
549+ * entry in the shade instead of filling it. */
550+ @SuppressLint("MissingPermission")
551+ private fun postGroupSummary(context: Context, channelId: String, group: String, summaryId: Int) {
552+ if (!canPost(context)) return
553+ val summary = NotificationCompat.Builder(context, channelId)
554+ .setSmallIcon(R.drawable.ic_stat_notify)
555+ .setContentTitle(context.getString(R.string.app_name))
556+ .setAutoCancel(true)
557+ .setGroup(group)
558+ .setGroupSummary(true)
559+ .build()
560+ runCatching {
561+ NotificationManagerCompat.from(context).notify(summaryId, summary)
562+ }.onFailure { Log.w(TAG, "Failed to post group summary notification", it) }
563+ }
564+}
app/src/main/java/soy/iko/opencode/platform/AppShortcuts.ktnew file+65
@@ -0,0 +1,65 @@
1+package soy.iko.opencode.platform
2+
3+import android.content.Context
4+import android.content.Intent
5+import android.net.Uri
6+import androidx.core.content.pm.ShortcutInfoCompat
7+import androidx.core.content.pm.ShortcutManagerCompat
8+import androidx.core.graphics.drawable.IconCompat
9+import soy.iko.opencode.MainActivity
10+import soy.iko.opencode.R
11+import soy.iko.opencode.data.repo.RecentSession
12+import soy.iko.opencode.notification.NotificationActionReceiver
13+
14+/**
15+ * Maintains the app's dynamic launcher shortcuts (long-press the icon): "New session" and
16+ * "Resume last". Rebuilt whenever the recent-session list changes so "Resume last" points at
17+ * the most recent conversation via the existing `opencode://session/{id}` deep link.
18+ */
19+object AppShortcuts {
20+
21+ private const val ID_NEW = "new_session"
22+ private const val ID_RESUME = "resume_last"
23+ private val idRegex = Regex("[^A-Za-z0-9_-]")
24+
25+ fun update(context: Context, lastSession: RecentSession?) {
26+ val ctx = context.applicationContext
27+ val shortcuts = mutableListOf<ShortcutInfoCompat>()
28+
29+ shortcuts += ShortcutInfoCompat.Builder(ctx, ID_NEW)
30+ .setShortLabel(ctx.getString(R.string.shortcut_new_short))
31+ .setLongLabel(ctx.getString(R.string.shortcut_new_long))
32+ .setIcon(IconCompat.createWithResource(ctx, R.drawable.ic_launcher_foreground))
33+ .setIntent(
34+ Intent(ctx, MainActivity::class.java).apply { action = MainActivity.ACTION_NEW_SESSION },
35+ )
36+ .build()
37+
38+ if (lastSession != null) {
39+ val safeId = idRegex.replace(lastSession.id, "")
40+ if (safeId.isNotEmpty()) {
41+ shortcuts += ShortcutInfoCompat.Builder(ctx, ID_RESUME)
42+ .setShortLabel(ctx.getString(R.string.shortcut_resume_short))
43+ .setLongLabel(
44+ lastSession.title.ifBlank { ctx.getString(R.string.shortcut_resume_long) }.take(40),
45+ )
46+ .setIcon(IconCompat.createWithResource(ctx, R.drawable.ic_launcher_foreground))
47+ .setIntent(
48+ Intent(ctx, MainActivity::class.java).apply {
49+ action = Intent.ACTION_VIEW
50+ data = Uri.parse("opencode://session/$safeId")
51+ // Embed the profile id so the session opens under the server that
52+ // ran it, not whichever is active when the user taps — mirrors the
53+ // widget and notification action paths.
54+ if (lastSession.profileId.isNotBlank()) {
55+ putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, lastSession.profileId)
56+ }
57+ },
58+ )
59+ .build()
60+ }
61+ }
62+
63+ runCatching { ShortcutManagerCompat.setDynamicShortcuts(ctx, shortcuts) }
64+ }
65+}
app/src/main/java/soy/iko/opencode/platform/NewSessionTileService.ktnew file+33
@@ -0,0 +1,33 @@
1+package soy.iko.opencode.platform
2+
3+import android.app.PendingIntent
4+import android.content.Intent
5+import android.os.Build
6+import android.service.quicksettings.TileService
7+import soy.iko.opencode.MainActivity
8+
9+/**
10+ * Quick-settings tile that starts a new opencode session. Tapping launches [MainActivity]
11+ * with [MainActivity.ACTION_NEW_SESSION]; the app creates and opens a fresh session once
12+ * connected. On Android 14+ [startActivityAndCollapse] requires a PendingIntent.
13+ */
14+class NewSessionTileService : TileService() {
15+
16+ override fun onClick() {
17+ super.onClick()
18+ val intent = Intent(this, MainActivity::class.java).apply {
19+ action = MainActivity.ACTION_NEW_SESSION
20+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
21+ }
22+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
23+ val pending = PendingIntent.getActivity(
24+ this, 0, intent,
25+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
26+ )
27+ startActivityAndCollapse(pending)
28+ } else {
29+ @Suppress("DEPRECATION", "StartActivityAndCollapseDeprecated")
30+ startActivityAndCollapse(intent)
31+ }
32+ }
33+}
app/src/main/java/soy/iko/opencode/platform/SessionsWidgetProvider.ktnew file+83
@@ -0,0 +1,83 @@
1+package soy.iko.opencode.platform
2+
3+import android.app.PendingIntent
4+import android.appwidget.AppWidgetManager
5+import android.appwidget.AppWidgetProvider
6+import android.content.ComponentName
7+import android.content.Context
8+import android.content.Intent
9+import android.net.Uri
10+import android.os.Build
11+import android.widget.RemoteViews
12+import soy.iko.opencode.MainActivity
13+import soy.iko.opencode.R
14+
15+/**
16+ * Home-screen widget listing recent opencode sessions (fed by
17+ * [soy.iko.opencode.data.repo.RecentSessionsStore]) with a "New session" action. Each row deep-links
18+ * into its session; the header opens the app. The list is backed by [SessionsWidgetService].
19+ */
20+class SessionsWidgetProvider : AppWidgetProvider() {
21+
22+ override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
23+ for (id in appWidgetIds) render(context, appWidgetManager, id)
24+ }
25+
26+ companion object {
27+ /** Re-render all widgets and tell them to reload their list data (call after the
28+ * recent-session list changes). No-op when no widget is placed. */
29+ fun refresh(context: Context) {
30+ val manager = AppWidgetManager.getInstance(context) ?: return
31+ val ids = runCatching {
32+ manager.getAppWidgetIds(ComponentName(context, SessionsWidgetProvider::class.java))
33+ }.getOrNull() ?: return
34+ if (ids.isEmpty()) return
35+ for (id in ids) render(context, manager, id)
36+ manager.notifyAppWidgetViewDataChanged(ids, R.id.widget_list)
37+ }
38+
39+ private fun render(context: Context, manager: AppWidgetManager, widgetId: Int) {
40+ val views = RemoteViews(context.packageName, R.layout.widget_sessions)
41+
42+ // Header: title opens the app; the "+" starts a new session.
43+ views.setOnClickPendingIntent(
44+ R.id.widget_title,
45+ activityPending(context, requestCode = 2, Intent(context, MainActivity::class.java)),
46+ )
47+ views.setOnClickPendingIntent(
48+ R.id.widget_new,
49+ activityPending(
50+ context, requestCode = 1,
51+ Intent(context, MainActivity::class.java).apply { action = MainActivity.ACTION_NEW_SESSION },
52+ ),
53+ )
54+
55+ // Bind the list to the RemoteViewsService. A unique data Uri per widget id keeps
56+ // the framework from reusing one adapter across widgets.
57+ val serviceIntent = Intent(context, SessionsWidgetService::class.java).apply {
58+ putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId)
59+ data = Uri.parse(toUri(Intent.URI_INTENT_SCHEME))
60+ }
61+ views.setRemoteAdapter(R.id.widget_list, serviceIntent)
62+ views.setEmptyView(R.id.widget_list, R.id.widget_empty)
63+
64+ // Template intent filled in per row (fill-in carries EXTRA_SESSION_ID). Must be
65+ // mutable on Android 12+ so the row's fill-in can be merged.
66+ val templateIntent = Intent(context, MainActivity::class.java).apply { action = Intent.ACTION_VIEW }
67+ val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0
68+ val templatePending = PendingIntent.getActivity(
69+ context, 3, templateIntent, PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,
70+ )
71+ views.setPendingIntentTemplate(R.id.widget_list, templatePending)
72+
73+ manager.updateAppWidget(widgetId, views)
74+ manager.notifyAppWidgetViewDataChanged(widgetId, R.id.widget_list)
75+ }
76+
77+ private fun activityPending(context: Context, requestCode: Int, intent: Intent): PendingIntent =
78+ PendingIntent.getActivity(
79+ context, requestCode, intent,
80+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
81+ )
82+ }
83+}
app/src/main/java/soy/iko/opencode/platform/SessionsWidgetService.ktnew file+72
@@ -0,0 +1,72 @@
1+package soy.iko.opencode.platform
2+
3+import android.content.Context
4+import android.content.Intent
5+import android.widget.RemoteViews
6+import android.widget.RemoteViewsService
7+import soy.iko.opencode.MainActivity
8+import soy.iko.opencode.R
9+import soy.iko.opencode.data.repo.RecentSession
10+import soy.iko.opencode.data.repo.RecentSessionsStore
11+import soy.iko.opencode.notification.NotificationActionReceiver
12+import java.security.MessageDigest
13+
14+/** Serves the list rows for [SessionsWidgetProvider] from [RecentSessionsStore]. */
15+class SessionsWidgetService : RemoteViewsService() {
16+ override fun onGetViewFactory(intent: Intent): RemoteViewsFactory =
17+ SessionsWidgetFactory(applicationContext)
18+}
19+
20+private class SessionsWidgetFactory(private val context: Context) : RemoteViewsService.RemoteViewsFactory {
21+
22+ private var items: List<RecentSession> = emptyList()
23+
24+ override fun onCreate() { items = RecentSessionsStore.read(context) }
25+
26+ // Re-read from disk whenever the app signals the data changed (notifyAppWidgetViewDataChanged).
27+ override fun onDataSetChanged() { items = RecentSessionsStore.read(context) }
28+
29+ override fun onDestroy() { items = emptyList() }
30+
31+ override fun getCount(): Int = items.size
32+
33+ override fun getViewAt(position: Int): RemoteViews {
34+ val row = RemoteViews(context.packageName, R.layout.widget_session_item)
35+ val session = items.getOrNull(position) ?: return row
36+ row.setTextViewText(R.id.widget_item_title, session.title.ifBlank { context.getString(R.string.session) })
37+ // Fill-in intent merged into the list's template: carries the session id the app opens,
38+ // plus the profile id of the server that ran the session so it opens under THAT server
39+ // even after the user has switched connections — mirrors the notification action path.
40+ val fillIn = Intent().putExtra(MainActivity.EXTRA_SESSION_ID, session.id)
41+ if (session.profileId.isNotBlank()) {
42+ fillIn.putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, session.profileId)
43+ }
44+ row.setOnClickFillInIntent(R.id.widget_item_root, fillIn)
45+ return row
46+ }
47+
48+ override fun getLoadingView(): RemoteViews? = null
49+ override fun getViewTypeCount(): Int = 1
50+ // Derive a stable id from the session id via a collision-resistant SHA-256 digest
51+ // (truncated to 63 bits) rather than String.hashCode(), whose 32-bit output can
52+ // collide for distinct session ids — with hasStableIds=true the RemoteViews framework
53+ // would then mis-recycle two sessions' row content. Mirrors SessionNotifications.notifId.
54+ // Falls back to the position for an out-of-range index.
55+ override fun getItemId(position: Int): Long {
56+ val id = items.getOrNull(position)?.id ?: return position.toLong()
57+ val digest = runCatching { MessageDigest.getInstance("SHA-256").digest(id.toByteArray()) }
58+ .getOrNull() ?: return position.toLong()
59+ // Take the first 8 bytes, mask to 63 bits (always positive) to fit a Long id.
60+ // The shl/or/and infix precedence requires parenthesizing each shift individually
61+ // (see SessionNotifications.notifId for the rationale).
62+ return (((digest[0].toLong() and 0xFF) shl 56) or
63+ ((digest[1].toLong() and 0xFF) shl 48) or
64+ ((digest[2].toLong() and 0xFF) shl 40) or
65+ ((digest[3].toLong() and 0xFF) shl 32) or
66+ ((digest[4].toLong() and 0xFF) shl 24) or
67+ ((digest[5].toLong() and 0xFF) shl 16) or
68+ ((digest[6].toLong() and 0xFF) shl 8) or
69+ (digest[7].toLong() and 0xFF)).and(0x7FFFFFFFFFFFFFFF)
70+ }
71+ override fun hasStableIds(): Boolean = true
72+}
app/src/main/java/soy/iko/opencode/ui/AppLockGate.ktnew file+312
@@ -0,0 +1,312 @@
1+package soy.iko.opencode.ui
2+
3+import android.content.Context
4+import android.content.ContextWrapper
5+import android.os.Build
6+import androidx.biometric.BiometricManager
7+import androidx.biometric.BiometricPrompt
8+import androidx.compose.foundation.clickable
9+import androidx.compose.foundation.layout.Arrangement
10+import androidx.compose.foundation.layout.Column
11+import androidx.compose.foundation.layout.Spacer
12+import androidx.compose.foundation.layout.fillMaxSize
13+import androidx.compose.foundation.layout.padding
14+import androidx.compose.foundation.layout.size
15+import androidx.compose.material.icons.Icons
16+import androidx.compose.material.icons.filled.Fingerprint
17+import androidx.compose.material.icons.filled.Lock
18+import androidx.compose.material3.Button
19+import androidx.compose.material3.Icon
20+import androidx.compose.material3.MaterialTheme
21+import androidx.compose.material3.Surface
22+import androidx.compose.material3.Text
23+import androidx.compose.runtime.Composable
24+import androidx.compose.runtime.DisposableEffect
25+import androidx.compose.runtime.LaunchedEffect
26+import androidx.compose.runtime.getValue
27+import androidx.compose.runtime.mutableStateOf
28+import androidx.compose.runtime.remember
29+import androidx.compose.runtime.saveable.rememberSaveable
30+import androidx.compose.runtime.saveable.rememberSaveableStateHolder
31+import androidx.compose.runtime.setValue
32+import androidx.compose.ui.Alignment
33+import androidx.compose.ui.Modifier
34+import androidx.compose.ui.platform.LocalContext
35+import androidx.compose.ui.res.stringResource
36+import androidx.compose.ui.semantics.LiveRegionMode
37+import androidx.compose.ui.semantics.Role
38+import androidx.compose.ui.semantics.liveRegion
39+import androidx.compose.ui.semantics.semantics
40+import androidx.compose.ui.unit.dp
41+import androidx.core.content.ContextCompat
42+import androidx.fragment.app.FragmentActivity
43+import androidx.lifecycle.Lifecycle
44+import androidx.lifecycle.LifecycleEventObserver
45+import androidx.lifecycle.compose.LocalLifecycleOwner
46+import soy.iko.opencode.R
47+import soy.iko.opencode.ui.components.LoadingSize
48+import soy.iko.opencode.ui.components.LoadingSpinner
49+
50+/**
51+ * The set of authenticators the app lock accepts. Device credential (PIN/pattern/password)
52+ * can only be combined with a biometric class from Android 11 (API 30) on; on 26–29 we ask
53+ * for a strong biometric alone, so the lock is only offered (in Settings) when one is enrolled.
54+ */
55+private val lockAuthenticators: Int
56+ get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
57+ BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL
58+ } else {
59+ BiometricManager.Authenticators.BIOMETRIC_STRONG
60+ }
61+
62+/** Whether device authentication is currently possible, so Settings can gate the toggle. */
63+fun canAuthenticateForAppLock(context: Context): Boolean =
64+ BiometricManager.from(context).canAuthenticate(lockAuthenticators) == BiometricManager.BIOMETRIC_SUCCESS
65+
66+/**
67+ * Whether app-lock auth is *permanently* impossible on this device (no hardware, nothing
68+ * enrolled, or a pending security update). Only these states may fail the gate open. A transient
69+ * error (sensor busy / temporarily unavailable) must NOT fail open — that would unlock the app on
70+ * a momentary glitch — so the gate stays locked and lets the user retry.
71+ */
72+private fun appLockPermanentlyUnavailable(context: Context): Boolean =
73+ when (BiometricManager.from(context).canAuthenticate(lockAuthenticators)) {
74+ BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED,
75+ BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE,
76+ BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> true
77+ else -> false
78+ }
79+
80+/** Unwrap a [FragmentActivity] from a (possibly wrapped) Compose [Context]. */
81+private fun Context.findFragmentActivity(): FragmentActivity? {
82+ var ctx: Context? = this
83+ while (ctx is ContextWrapper) {
84+ if (ctx is FragmentActivity) return ctx
85+ ctx = ctx.baseContext
86+ }
87+ return null
88+}
89+
90+/**
91+ * Gates [content] behind device authentication when [enabled]. The content stays composed
92+ * underneath an opaque lock overlay (so navigation state survives a re-lock), and the app
93+ * re-locks whenever it's sent to the background. If no [FragmentActivity] is available or the
94+ * device can't authenticate, the gate is a no-op — the setting is only offered when
95+ * [canAuthenticateForAppLock] is true, so this fallback just prevents a hard lock-out.
96+ */
97+@Composable
98+fun AppLockGate(enabled: Boolean, reLockDelaySeconds: Int = 0, content: @Composable () -> Unit) {
99+ if (!enabled) {
100+ content()
101+ return
102+ }
103+ val context = LocalContext.current
104+ val activity = remember(context) { context.findFragmentActivity() }
105+ if (activity == null) {
106+ content()
107+ return
108+ }
109+ // Fail open only if device authentication is *permanently* impossible (e.g. the user enabled
110+ // app-lock while a credential was enrolled and later removed their PIN/biometric): authenticate()
111+ // would otherwise always hit onAuthenticationError and strand the user on a permanent lockout.
112+ // A transient authenticator error keeps the lock up (the user retries) rather than unlocking.
113+ if (appLockPermanentlyUnavailable(context)) {
114+ // Surface a one-time warning so the user knows their app-lock setting is on but isn't
115+ // actually protecting the app — without this they'd have to discover it by opening
116+ // Settings and seeing the "unavailable" state. A Toast (not a snackbar) because the
117+ // gate sits above the Scaffold/SnackbarHost; the warning is informational, not an action.
118+ LaunchedEffect(Unit) {
119+ android.widget.Toast.makeText(
120+ context,
121+ context.getString(R.string.app_lock_disabled_warning),
122+ android.widget.Toast.LENGTH_LONG,
123+ ).show()
124+ }
125+ content()
126+ return
127+ }
128+
129+ // Deliberately NOT rememberSaveable: the unlocked flag must never be persisted into the
130+ // saved-instance-state Bundle. On API 26-27 onSaveInstanceState runs *before* onStop, so a
131+ // saveable `true` would be written to the Bundle before ON_STOP resets it; after a background
132+ // process-kill and restore the app would come back unlocked with no biometric prompt — an
133+ // app-lock bypass. Rotation is handled by the activity's configChanges (no recreation), so a
134+ // plain remember still avoids re-prompting on rotation while re-locking on real recreation.
135+ var unlocked by remember { mutableStateOf(false) }
136+ // Wall-clock time of the most recent ON_STOP. Used with [reLockDelaySeconds] so a quick
137+ // app-switch (e.g. glancing at another app for a few seconds) doesn't re-prompt — the most
138+ // common reason users disable biometric locks. A killed process resets unlocked anyway, so the
139+ // grace period only governs the in-memory re-lock decision across a surviving background stint.
140+ //
141+ // Plain remember (not rememberSaveable), matching `unlocked` above: the grace check gates on
142+ // `unlocked`, which is non-saveable, so persisting only the stop timestamp through the Bundle
143+ // is inconsistent — on a config change NOT in the manifest's configChanges (e.g. a locale
144+ // change) `unlocked` resets to false while `lastStopTimeMs` would survive, and the grace
145+ // check `if (unlocked && ...)` short-circuits to false anyway, re-prompting the user despite
146+ // having unlocked seconds ago. Resetting both together on recreation is consistent: the new
147+ // DisposableEffect observer gets ON_START synchronously, sees lastStopTimeMs == 0, and prompts
148+ // cleanly (a recreation mid-grace re-prompts, which is the safe default for an app lock).
149+ val lastStopTimeMs = remember { longArrayOf(0L) }
150+ val reLockDelayMs = reLockDelaySeconds * 1000L
151+
152+ val title = stringResource(R.string.app_lock_prompt_title)
153+ val subtitle = stringResource(R.string.app_lock_prompt_subtitle)
154+
155+ // Track an in-flight prompt so a recomposition (or the retry button) can't stack two
156+ // BiometricPrompts, which throws.
157+ var authInFlight by remember { mutableStateOf(false) }
158+ // Monotonic id for the current prompt. The system dismisses an in-flight BiometricPrompt when
159+ // the app backgrounds and posts its onAuthenticationError asynchronously; without this, that
160+ // stale error could land after ON_START already started a NEW prompt and clear authInFlight
161+ // mid-prompt — letting a recomposition or the "Unlock" button (enabled = !authInFlight) stack
162+ // a second prompt. Each prompt captures its id and only mutates state while it's still current.
163+ val promptId = remember { intArrayOf(0) }
164+
165+ fun authenticate() {
166+ if (authInFlight || unlocked) return
167+ authInFlight = true
168+ val myPrompt = ++promptId[0]
169+ val executor = ContextCompat.getMainExecutor(activity)
170+ val callback = object : BiometricPrompt.AuthenticationCallback() {
171+ override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
172+ if (myPrompt != promptId[0]) return
173+ authInFlight = false
174+ unlocked = true
175+ }
176+
177+ override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
178+ // User cancelled / lockout / hardware error: stay locked and let them retry
179+ // via the button rather than looping the system prompt. Ignore a stale error
180+ // from a prompt already superseded by a newer one (see promptId above).
181+ if (myPrompt != promptId[0]) return
182+ authInFlight = false
183+ }
184+
185+ override fun onAuthenticationFailed() {
186+ // A single non-matching attempt; the system UI lets the user try again.
187+ }
188+ }
189+ val info = BiometricPrompt.PromptInfo.Builder()
190+ .setTitle(title)
191+ .setSubtitle(subtitle)
192+ .setAllowedAuthenticators(lockAuthenticators)
193+ .build()
194+ runCatching { BiometricPrompt(activity, executor, callback).authenticate(info) }
195+ .onFailure { if (myPrompt == promptId[0]) authInFlight = false }
196+ }
197+
198+ // Re-lock when the app is backgrounded, and re-prompt when it returns to the foreground.
199+ // The re-prompt fires on ON_START rather than by keying an effect on `unlocked`: ON_STOP
200+ // already cleared `unlocked` while backgrounded, so an effect keyed on it would see no
201+ // change on return and never re-fire, stranding the user on the manual "Unlock" button.
202+ // addObserver also delivers the current state's ON_START to a freshly-added observer, so
203+ // this covers the initial prompt on first show too; the authInFlight/unlocked guards in
204+ // authenticate() make the overlapping triggers safe.
205+ val lifecycleOwner = LocalLifecycleOwner.current
206+ DisposableEffect(lifecycleOwner) {
207+ val observer = LifecycleEventObserver { _, event ->
208+ when (event) {
209+ Lifecycle.Event.ON_STOP -> {
210+ // Record the stop time but DON'T clear unlocked yet — the re-lock decision is
211+ // made on the next ON_START, so a brief background stint under the grace period
212+ // returns the user straight to their session without a re-prompt. authInFlight
213+ // is still cleared because the system dismisses an in-flight prompt on background.
214+ lastStopTimeMs[0] = System.currentTimeMillis()
215+ authInFlight = false
216+ }
217+ Lifecycle.Event.ON_START -> {
218+ // Only re-lock (and re-prompt) if more than the grace period elapsed since the
219+ // last ON_STOP, or the app is freshly starting (lastStopTimeMs == 0).
220+ val elapsed = System.currentTimeMillis() - lastStopTimeMs[0]
221+ if (unlocked && lastStopTimeMs[0] != 0L && elapsed < reLockDelayMs) {
222+ // Within the grace period — stay unlocked, no re-prompt.
223+ } else {
224+ unlocked = false
225+ authenticate()
226+ }
227+ }
228+ else -> {}
229+ }
230+ }
231+ lifecycleOwner.lifecycle.addObserver(observer)
232+ onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
233+ }
234+
235+ // Compose content ONLY while unlocked. Drawing the lock as an overlay *over* a still-composed
236+ // content() cannot cover a Dialog / ModalBottomSheet / DropdownMenu / Popup: those render in
237+ // separate windows z-ordered above the Activity content, so an open permission dialog or picker
238+ // sheet would stay visible and interactive above the lock — an authentication bypass. Not
239+ // composing content while locked removes those sub-windows entirely. The SaveableStateHolder
240+ // preserves navigation/scroll/input state across the re-lock so unlocking returns the user where
241+ // they were, replacing the previous "keep composed underneath" approach.
242+ val stateHolder = rememberSaveableStateHolder()
243+ if (unlocked) {
244+ stateHolder.SaveableStateProvider(APP_LOCK_CONTENT_KEY) { content() }
245+ } else {
246+ // A Fingerprint glyph hints at the primary unlock method when a biometric is enrolled;
247+ // fall back to the generic lock on credential-only setups. Computed once: enrollment
248+ // doesn't change while the gate is shown, and re-prompting on each frame is wasteful.
249+ val biometricAvailable = remember(context) {
250+ BiometricManager.from(context)
251+ .canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) ==
252+ BiometricManager.BIOMETRIC_SUCCESS
253+ }
254+ val unlockLabel = stringResource(R.string.app_unlock)
255+ Surface(
256+ modifier = Modifier.fillMaxSize(),
257+ color = MaterialTheme.colorScheme.background,
258+ ) {
259+ // The whole surface is tappable to re-trigger the prompt (in addition to the button),
260+ // so a user who dismissed the system prompt can retry by tapping anywhere — not just
261+ // by finding the Unlock button.
262+ Column(
263+ modifier = Modifier
264+ .fillMaxSize()
265+ .clickable(
266+ enabled = !authInFlight,
267+ role = Role.Button,
268+ onClickLabel = unlockLabel,
269+ ) { authenticate() }
270+ .padding(32.dp),
271+ horizontalAlignment = Alignment.CenterHorizontally,
272+ verticalArrangement = Arrangement.Center,
273+ ) {
274+ Icon(
275+ if (biometricAvailable) Icons.Filled.Fingerprint else Icons.Filled.Lock,
276+ contentDescription = null,
277+ modifier = Modifier.size(56.dp),
278+ tint = MaterialTheme.colorScheme.primary,
279+ )
280+ Text(
281+ stringResource(R.string.app_locked),
282+ style = MaterialTheme.typography.titleMedium,
283+ modifier = Modifier
284+ .padding(top = 16.dp)
285+ .semantics { liveRegion = LiveRegionMode.Polite },
286+ )
287+ Text(
288+ stringResource(R.string.app_locked_hint),
289+ style = MaterialTheme.typography.bodyMedium,
290+ color = MaterialTheme.colorScheme.onSurfaceVariant,
291+ modifier = Modifier.padding(top = 4.dp),
292+ )
293+ Button(
294+ onClick = { authenticate() },
295+ enabled = !authInFlight,
296+ modifier = Modifier.padding(top = 24.dp),
297+ ) {
298+ if (authInFlight) {
299+ LoadingSpinner(
300+ size = LoadingSize.Small,
301+ color = MaterialTheme.colorScheme.primary,
302+ )
303+ Spacer(Modifier.size(8.dp))
304+ }
305+ Text(unlockLabel)
306+ }
307+ }
308+ }
309+ }
310+}
311+
312+private const val APP_LOCK_CONTENT_KEY = "app_lock_content"
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+607−90
@@ -1,124 +1,641 @@
11package soy.iko.opencode.ui
22
3−import androidx.activity.compose.BackHandler
4−import androidx.compose.animation.AnimatedContent
3+import androidx.compose.animation.EnterTransition
4+import androidx.compose.animation.ExitTransition
5+import androidx.compose.animation.core.tween
6+import androidx.compose.animation.fadeIn
7+import androidx.compose.animation.fadeOut
58import androidx.compose.animation.slideInHorizontally
69import androidx.compose.animation.slideOutHorizontally
7−import androidx.compose.animation.togetherWith
10+import androidx.compose.foundation.layout.Box
11+import androidx.compose.foundation.layout.BoxWithConstraints
12+import androidx.compose.foundation.layout.Row
13+import androidx.compose.foundation.layout.fillMaxHeight
814import androidx.compose.foundation.layout.fillMaxSize
9−import androidx.compose.foundation.layout.padding
10−import androidx.compose.material3.AlertDialog
11−import androidx.compose.material3.Scaffold
12−import androidx.compose.material3.SnackbarDuration
13−import androidx.compose.material3.SnackbarHost
14−import androidx.compose.material3.SnackbarHostState
15−import androidx.compose.material3.Text
16−import androidx.compose.material3.TextButton
1715import androidx.compose.runtime.Composable
16+import androidx.compose.runtime.CompositionLocalProvider
1817import androidx.compose.runtime.LaunchedEffect
1918import androidx.compose.runtime.getValue
2019import androidx.compose.runtime.mutableStateOf
2120import androidx.compose.runtime.remember
21+import androidx.compose.runtime.rememberCoroutineScope
22+import androidx.compose.runtime.saveable.rememberSaveable
2223import androidx.compose.runtime.setValue
2324import androidx.compose.ui.Modifier
24−import soy.iko.opencode.core.Event
25−import soy.iko.opencode.core.Screen
26−import soy.iko.opencode.core.UiState
27−import soy.iko.opencode.ui.screens.ChatScreen
28−import soy.iko.opencode.ui.screens.ConnectScreen
29−import soy.iko.opencode.ui.screens.SessionsScreen
25+import androidx.compose.ui.input.key.Key
26+import androidx.compose.ui.input.key.KeyEventType
27+import androidx.compose.ui.input.key.isCtrlPressed
28+import androidx.compose.ui.input.key.isAltPressed
29+import androidx.compose.ui.input.key.key
30+import androidx.compose.ui.input.key.onPreviewKeyEvent
31+import androidx.compose.ui.input.key.type
32+import androidx.compose.ui.platform.LocalContext
33+import androidx.compose.ui.platform.LocalDensity
34+import androidx.compose.ui.unit.dp
35+import androidx.compose.ui.unit.LayoutDirection
36+import androidx.compose.ui.platform.LocalLayoutDirection
37+import androidx.navigation.NavType
38+import androidx.navigation.NavGraph.Companion.findStartDestination
39+import androidx.navigation.compose.NavHost
40+import androidx.navigation.compose.composable
41+import androidx.navigation.compose.currentBackStackEntryAsState
42+import androidx.navigation.compose.rememberNavController
43+import androidx.navigation.navArgument
44+import androidx.lifecycle.compose.collectAsStateWithLifecycle
45+import kotlinx.coroutines.launch
46+import soy.iko.opencode.di.AppContainer
47+import soy.iko.opencode.data.network.NetworkConfig
48+import soy.iko.opencode.ui.chat.ChatScreen
49+import soy.iko.opencode.ui.file.FileBrowserScreen
50+import soy.iko.opencode.ui.file.FileViewScreen
51+import soy.iko.opencode.ui.file.TwoPaneFileBrowser
52+import soy.iko.opencode.ui.server.ServerEditScreen
53+import soy.iko.opencode.ui.server.ServerListScreen
54+import soy.iko.opencode.ui.server.ServerSettingsScreen
55+import soy.iko.opencode.ui.search.GlobalSearchScreen
56+import androidx.compose.ui.platform.LocalHapticFeedback
57+import soy.iko.opencode.ui.components.LargeScreenNavRail
58+import soy.iko.opencode.ui.components.CompactNavBar
59+import soy.iko.opencode.ui.components.LocalChatTextScale
60+import soy.iko.opencode.ui.components.LocalCodeWrap
61+import soy.iko.opencode.ui.components.LocalReducedMotion
62+import soy.iko.opencode.ui.components.isReducedMotion
63+import soy.iko.opencode.ui.components.rememberGatedHaptics
64+import soy.iko.opencode.ui.session.SessionListScreen
65+import soy.iko.opencode.ui.session.TwoPaneSessionChat
66+import soy.iko.opencode.ui.settings.DiagnosticsScreen
67+import soy.iko.opencode.ui.settings.SettingsScreen
68+import soy.iko.opencode.ui.usage.UsageScreen
69+import soy.iko.opencode.ui.mcp.McpScreen
70+import soy.iko.opencode.util.findActivity
71+import soy.iko.opencode.util.runCatchingCancellable
3072
3173@Composable
32−fun OpencodeApp(state: UiState, dispatch: (Event) -> Unit) {
33− val snackbar = remember { SnackbarHostState() }
34− var showStopConfirm by remember { mutableStateOf(false) }
35− var showDisconnectConfirm by remember { mutableStateOf(false) }
36−
37− LaunchedEffect(state.error) {
38− state.error?.let {
39− snackbar.showSnackbar(
40− message = it,
41− actionLabel = "Dismiss",
42− duration = SnackbarDuration.Short,
43− )
44− // Always clear the error from state after showing.
45− dispatch(Event.DismissError)
74+fun OpencodeApp(container: AppContainer) {
75+ val navController = rememberNavController()
76+ val scope = rememberCoroutineScope()
77+ val pendingShare by container.pendingShare.collectAsStateWithLifecycle()
78+ val pendingSharedMedia by container.pendingSharedMedia.collectAsStateWithLifecycle()
79+ val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
80+ val pendingNewSession by container.pendingNewSession.collectAsStateWithLifecycle()
81+ val pendingOpenFile by container.pendingOpenFile.collectAsStateWithLifecycle()
82+ val pendingDiagnostics by container.pendingDiagnostics.collectAsStateWithLifecycle()
83+ val connection by container.activeConnection.collectAsStateWithLifecycle()
84+ // Chat presentation preferences, provided to the whole nav graph so the markdown/code
85+ // renderers honor the user's text-size and code-wrap choices everywhere.
86+ val chatTextScale by container.settingsStore.chatTextScale.collectAsStateWithLifecycle(initialValue = 1f)
87+ val codeWrap by container.settingsStore.codeWrap.collectAsStateWithLifecycle(initialValue = false)
88+ // Haptics gating: collected once at the root so the in-app toggle can disable all
89+ // performHapticFeedback calls everywhere via a single CompositionLocalProvider, instead
90+ // of each call site needing to read the setting. Defaults true (the prior behavior) so
91+ // the brief window before DataStore loads still haptics as before.
92+ val hapticsEnabled by container.settingsStore.hapticsEnabled.collectAsStateWithLifecycle(initialValue = true)
93+
94+ // The foreground service that holds priority while any run is active is driven from the
95+ // process-lived AppContainer scope (see AppContainer.observeRunForegroundService), not
96+ // from here — a composition-scoped collector pauses while backgrounded, which is exactly
97+ // when a run started via the notification reply needs the service to start.
98+
99+ // When text is shared into the app, surface the session list so the user can pick
100+ // (or create) a conversation to drop it into. The chosen session's draft is set
101+ // in [onOpenSession] below. Keyed on `connection` too so a share that arrives
102+ // before auto-reconnect completes is retried once the connection is established.
103+ // Track the last-handled share payloads (there's no share nonce on the container) so a
104+ // *new* share arriving while already connected — a warm start where onNewIntent re-fires
105+ // setPendingShare under an unchanged connection id — still navigates, while a re-trigger
106+ // carrying no new share (a bare server switch, which routes through a null connection, or
107+ // a share the user already navigated for) leaves them where they are.
108+ //
109+ // Reset the remembered media when the pending list is drained (the chosen ChatScreen
110+ // consumes it via consumePendingSharedMedia). Without this, re-sharing the *same* images
111+ // after consumption compares equal structurally ([A,B] == [A,B]) and would be treated as
112+ // "already handled", silently skipping the session-list navigation the second time.
113+ //
114+ // rememberSaveable (not remember) so a process-kill restore doesn't reset the dedup
115+ // state and re-navigate to SESSIONS for a share the user already handled before the kill.
116+ var handledShareText by rememberSaveable { mutableStateOf<String?>(null) }
117+ var handledSharedMedia by rememberSaveable { mutableStateOf<List<String>>(emptyList()) }
118+ LaunchedEffect(pendingShare, pendingSharedMedia, connection) {
119+ // Reset the remembered handled state for each channel independently as it drains,
120+ // so re-sharing the same payload after consumption is still recognized as new. A
121+ // joint reset (only when both are empty) would drop a text-only re-share that
122+ // follows a text+media share of the same text: the text channel matches its
123+ // remembered value while the media channel is empty (not "new"), so neither clause
124+ // fires and the share is silently swallowed.
125+ if (pendingShare == null) handledShareText = null
126+ if (pendingSharedMedia.isEmpty()) handledSharedMedia = emptyList()
127+ if (pendingShare == null && pendingSharedMedia.isEmpty()) return@LaunchedEffect
128+ if (connection == null) return@LaunchedEffect
129+ val newShare = (pendingShare != null && pendingShare != handledShareText) ||
130+ (pendingSharedMedia.isNotEmpty() && pendingSharedMedia != handledSharedMedia)
131+ if (!newShare) return@LaunchedEffect
132+ handledShareText = pendingShare
133+ handledSharedMedia = pendingSharedMedia
134+ if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
135+ navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
46136 }
47137 }
48138
49− BackHandler(enabled = state.screen != Screen.Connect) {
50− when (state.screen) {
51− // Leaving mid-generation would silently drop the reply, so confirm first.
52− Screen.Chat -> if (state.generating) showStopConfirm = true else dispatch(Event.NavigateToSessions)
53− // Disconnecting drops the server session, so guard the back button.
54− Screen.Sessions -> showDisconnectConfirm = true
55− Screen.Connect -> Unit
139+ // Crash-relaunch prompt: navigate to the Diagnostics screen when the user taps
140+ // "View report" on the cold-start crash dialog. Doesn't require a connection.
141+ LaunchedEffect(pendingDiagnostics) {
142+ if (!pendingDiagnostics) return@LaunchedEffect
143+ container.consumePendingDiagnostics()
144+ navController.navigate(Routes.DIAGNOSTICS) { launchSingleTop = true }
145+ }
146+
147+ // "New session" from a launcher shortcut / QS tile: once connected, create a fresh
148+ // session and open it. The pending counter is consumed only *after* createSession
149+ // succeeds, so a transient failure leaves the request pending to retry (on the next
150+ // connection change or a re-tap that increments the counter) instead of silently
151+ // dropping the tap.
152+ LaunchedEffect(pendingNewSession, connection) {
153+ if (pendingNewSession == 0) return@LaunchedEffect
154+ val conn = connection ?: return@LaunchedEffect
155+ val session = runCatchingCancellable { conn.repository.createSession() }.getOrNull()
156+ ?: return@LaunchedEffect
157+ if (!container.consumePendingNewSession()) return@LaunchedEffect
158+ // The file browser/viewer "Open in chat" action queues an @path reference as a
159+ // pending share alongside the new-session request. Unlike onOpenSession (which
160+ // injects it there), this effect creates the session directly and navigates to it
161+ // — so consume the share here and seed the new session's draft, otherwise the
162+ // text stays queued and silently misfires into the next session the user opens
163+ // from the list. Mirror the onOpenSession pattern: set in-memory synchronously
164+ // so ChatScreen sees it on first composition, then persist to disk in the back.
165+ container.consumePendingShare()?.let { shareText ->
166+ container.draftStore.setImmediate(session.id, shareText)
167+ scope.launch { runCatchingCancellable { container.draftStore.set(session.id, shareText) } }
56168 }
169+ if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
170+ navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
171+ }
172+ navController.navigate(Routes.chat(session.id)) { launchSingleTop = true }
57173 }
58174
59− if (showStopConfirm) {
60− AlertDialog(
61− onDismissRequest = { showStopConfirm = false },
62− title = { Text("Stop generation?") },
63− text = { Text("Leaving will stop the current generation.") },
64− confirmButton = {
65− TextButton(onClick = {
66− showStopConfirm = false
67− dispatch(Event.CancelGeneration)
68− dispatch(Event.NavigateToSessions)
69− }) {
70− Text("Stop and leave")
71− }
72− },
73− dismissButton = {
74− TextButton(onClick = { showStopConfirm = false }) {
75− Text("Stay")
76− }
77− },
175+ // The chat text-size preference multiplies on top of the OS font scale (sp already
176+ // honors the system setting), so a large system size × large app size can reach ~2×
177+ // and break tight layouts. Clamp the provided app scale so the product with the OS
178+ // fontScale never exceeds NetworkConfig.maxCombinedFontScale, while never scaling
179+ // below the user's chosen value's intent (floor of 1× app scale is preserved when the
180+ // system size alone already exceeds the cap).
181+ val systemFontScale = LocalDensity.current.fontScale
182+ val clampedChatTextScale = if (systemFontScale <= 0f) {
183+ chatTextScale
184+ } else {
185+ chatTextScale.coerceAtMost(
186+ (NetworkConfig.maxCombinedFontScale / systemFontScale).coerceAtLeast(1f)
78187 )
79188 }
80189
81− if (showDisconnectConfirm) {
82− AlertDialog(
83− onDismissRequest = { showDisconnectConfirm = false },
84− title = { Text("Disconnect?") },
85− text = { Text("You will be disconnected from the server.") },
86− confirmButton = {
87− TextButton(onClick = {
88− showDisconnectConfirm = false
89− dispatch(Event.NavigateToConnect)
90− }) {
91− Text("Disconnect")
190+ // Capture the reduced-motion preference once (in the composable scope) so the
191+ // non-composable NavHost transition lambdas can use it to skip animations, and provide
192+ // it via LocalReducedMotion so in-composition animations (chat row placement, banners,
193+ // image zoom) honor the same accessibility preference.
194+ val reducedMotion = isReducedMotion()
195+ CompositionLocalProvider(
196+ LocalChatTextScale provides clampedChatTextScale,
197+ LocalCodeWrap provides codeWrap,
198+ LocalReducedMotion provides reducedMotion,
199+ LocalHapticFeedback provides rememberGatedHaptics(hapticsEnabled),
200+ ) {
201+ // Slide direction for push/pop transitions, mirrored in RTL so the animation reads
202+ // naturally instead of "going the wrong way". +1 = LTR (push from right), -1 = RTL.
203+ val slideDir = if (LocalLayoutDirection.current == LayoutDirection.Rtl) -1 else 1
204+ // Adaptive: on wide screens (tablets / unfolded foldables) show the session list and
205+ // the open conversation side by side instead of a single-pane back stack.
206+ // Posture-aware: a foldable in HALF_OPENED (tabletop/book) mode has a hinge across the
207+ // content area — splitting there would place the list/detail boundary on the hinge, which
208+ // is hard to read and interact with. Detect a FoldingFeature and suppress two-pane in
209+ // those postures so the app falls back to a single-pane layout that respects the fold.
210+ val windowContext = LocalContext.current
211+ var foldState by remember { mutableStateOf<androidx.window.layout.FoldingFeature?>(null) }
212+ androidx.compose.runtime.LaunchedEffect(windowContext) {
213+ val activity = windowContext.findActivity()
214+ if (activity != null) {
215+ androidx.window.layout.WindowInfoTracker.getOrCreate(windowContext)
216+ .windowLayoutInfo(activity)
217+ .collect { info ->
218+ foldState = info.displayFeatures
219+ .filterIsInstance<androidx.window.layout.FoldingFeature>()
220+ .firstOrNull()
221+ }
222+ }
223+ }
224+ val isHalfOpened = foldState?.state == androidx.window.layout.FoldingFeature.State.HALF_OPENED
225+ BoxWithConstraints(
226+ modifier = Modifier.onPreviewKeyEvent { ev ->
227+ // Global keyboard shortcuts for hardware-keyboard users (tablets, DeX, Chromebook):
228+ // Ctrl+1..6 switches between top-level destinations. Only fires on KeyDown so a
229+ // held Ctrl doesn't re-trigger on every repeat. Per-screen shortcuts (Ctrl+K palette,
230+ // Esc stop, Ctrl+F find) are handled in each screen's own onPreviewKeyEvent and take
231+ // precedence (they're closer to the focused composable in the tree).
232+ if (ev.type != KeyEventType.KeyDown || !ev.isCtrlPressed) {
233+ // Alt+Left (a familiar desktop "Back" gesture) pops the back stack. Not gated
234+ // on Ctrl so it works as a standalone modifier. Per-screen shortcuts (Esc
235+ // stop, Ctrl+F find, Ctrl+K palette) register their own onPreviewKeyEvent
236+ // closer to the focused composable and consume first, so this only pops when
237+ // nothing nearer handled the key — avoiding conflicts with open dialogs and
238+ // text fields. Esc is deliberately NOT mapped to back here for that reason.
239+ if (ev.type == KeyEventType.KeyDown &&
240+ ev.key == Key.DirectionLeft &&
241+ ev.isAltPressed
242+ ) {
243+ if (navController.previousBackStackEntry != null) {
244+ navController.popBackStack()
245+ return@onPreviewKeyEvent true
246+ }
247+ }
248+ return@onPreviewKeyEvent false
249+ }
250+ val route = when (ev.key) {
251+ Key.NumPad1, Key.One -> Routes.SESSIONS
252+ Key.NumPad2, Key.Two -> Routes.SEARCH
253+ Key.NumPad3, Key.Three -> Routes.FILES
254+ Key.NumPad4, Key.Four -> Routes.USAGE
255+ Key.NumPad5, Key.Five -> Routes.MCP
256+ Key.NumPad6, Key.Six -> Routes.SETTINGS
257+ // Ctrl+7 navigates to the server list (the 7th nav-rail destination).
258+ // Previously Ctrl+1..6 covered the other destinations but Servers was
259+ // missing, an inconsistency since the nav rail shows 7 destinations.
260+ Key.NumPad7, Key.Seven -> Routes.SERVERS
261+ else -> null
262+ }
263+ if (route != null) {
264+ // Servers is reachable even without an active connection (it's the start
265+ // destination and handles the not-connected state); the other routes
266+ // require a connection to be meaningful.
267+ if (route == Routes.SERVERS || connection != null) {
268+ navController.navigate(route) {
269+ popUpTo(navController.graph.findStartDestination().id) { saveState = true }
270+ launchSingleTop = true
271+ restoreState = true
272+ }
273+ true
274+ } else false
275+ } else false
276+ },
277+ ) {
278+ val isTwoPane = maxWidth >= NetworkConfig.twoPaneWidthThresholdDp.dp &&
279+ connection != null &&
280+ !isHalfOpened
281+ // On large screens, surface a navigation rail alongside the content so top-level
282+ // destinations (Sessions, Files, Search, …) are one tap away instead of buried in a
283+ // screen's overflow menu. Mirrors the standard large-screen M3 layout.
284+ val showNavRail = maxWidth >= NetworkConfig.navigationRailThresholdDp.dp
285+ // Observe the current destination reactively so the rail's selected item tracks navigation
286+ // (reading currentDestination once would go stale after the first composition).
287+ val currentEntry by navController.currentBackStackEntryAsState()
288+ val currentRoute = currentEntry?.destination?.route
289+
290+ // Open a session requested by a notification tap or deep link, once connected.
291+ LaunchedEffect(pendingOpenSession, connection, isTwoPane) {
292+ val pending = pendingOpenSession ?: return@LaunchedEffect
293+ if (connection == null) return@LaunchedEffect
294+ // If the notification body-tap carries a profile id from a different server,
295+ // switch to it first so the session opens under the server that ran it, not
296+ // whichever is active. The effect re-runs when connection changes (keyed on it),
297+ // so on the second run the profile matches and the open proceeds.
298+ if (pending.profileId != null && connection?.profile?.id != pending.profileId) {
299+ // If the originating profile no longer exists (e.g. deleted after the
300+ // notification was queued), connectByProfileId returns false: the active
301+ // connection is unchanged and none of this effect's keys change, so it
302+ // would never re-run — the request would sit in _pendingOpenSession
303+ // forever and re-trigger on every later connection change. Consume it so
304+ // the stale request is dropped, mirroring the invalid-deep-link path in
305+ // MainActivity.
306+ if (!container.connectByProfileId(pending.profileId)) {
307+ container.consumePendingOpenSession()
308+ return@LaunchedEffect
309+ }
310+ return@LaunchedEffect // re-run on the new connection
311+ }
312+ // In two-pane mode the detail pane (TwoPaneSessionChat, hosted on the SESSIONS
313+ // route) consumes the request and opens it in the detail pane. But it only
314+ // exists while the NavHost is on SESSIONS, so if the user is currently on
315+ // Files/Settings/FileView bring SESSIONS back to front first — otherwise the
316+ // pending request is never consumed and the session never opens. Don't consume
317+ // here; let TwoPaneSessionChat's effect do it once it's composed.
318+ if (isTwoPane) {
319+ if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
320+ navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
321+ }
322+ return@LaunchedEffect
323+ }
324+ container.consumePendingOpenSession()
325+ // Ensure SESSIONS sits on the back stack below the chat destination so backing
326+ // out of the chat lands on the session list (not the server list). Bring SESSIONS
327+ // to front (or push it if a cold-start deep link left only SERVERS on the stack),
328+ // then push chat on top — yielding SERVERS -> SESSIONS -> chat.
329+ if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
330+ navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
331+ }
332+ navController.navigate(Routes.chat(pending.sessionId)) { launchSingleTop = true }
333+ }
334+
335+ // Deep-linked file open (opencode://file/{path}): navigate to the file viewer once a
336+ // connection exists (the viewer fetches content from the server). Path comes pre-validated
337+ // by MainActivity's deep-link guard. In two-pane mode the detail pane (TwoPaneFileBrowser,
338+ // hosted on the FILES route) consumes the request into its detail pane, mirroring how
339+ // session deep links are routed — so don't consume or push the full-screen viewer here.
340+ LaunchedEffect(pendingOpenFile, connection, isTwoPane) {
341+ val path = pendingOpenFile ?: return@LaunchedEffect
342+ if (connection == null) return@LaunchedEffect
343+ if (isTwoPane) {
344+ if (!navController.popBackStack(Routes.FILES, inclusive = false)) {
345+ navController.navigate(Routes.FILES) { launchSingleTop = true }
346+ }
347+ return@LaunchedEffect
348+ }
349+ container.consumePendingOpenFile()
350+ if (!navController.popBackStack(Routes.FILES, inclusive = false)) {
351+ navController.navigate(Routes.FILES) { launchSingleTop = true }
352+ }
353+ navController.navigate(Routes.fileView(path)) { launchSingleTop = true }
354+ }
355+
356+ val navHost = @Composable {
357+ NavHost(
358+ modifier = Modifier.fillMaxSize(),
359+ navController = navController,
360+ startDestination = Routes.SERVERS,
361+ // Motion-aware transitions: respect the user's reduced-motion setting
362+ // (Developer Options → animator scale 0) by skipping animations entirely.
363+ // Per-destination tuning: a chat→chat navigation (switching peer conversations
364+ // within the same surface) crossfades instead of sliding, so it reads as a peer
365+ // switch rather than drilling into a new section. Top-level destinations and
366+ // detail pushes keep the horizontal slide.
367+ enterTransition = {
368+ if (reducedMotion) {
369+ EnterTransition.None
370+ } else if (targetState.destination.route?.startsWith("${Routes.CHAT}/") == true) {
371+ fadeIn(tween(NetworkConfig.motionFadeDurationMs))
372+ } else {
373+ slideInHorizontally(animationSpec = tween(NetworkConfig.motionSlideDurationMs)) { it * slideDir } +
374+ fadeIn(tween(NetworkConfig.motionFadeDurationMs))
92375 }
93376 },
94− dismissButton = {
95− TextButton(onClick = { showDisconnectConfirm = false }) {
96− Text("Cancel")
377+ exitTransition = {
378+ if (reducedMotion) {
379+ ExitTransition.None
380+ } else {
381+ fadeOut(tween(NetworkConfig.motionFadeDurationMs))
97382 }
98383 },
99− )
100− }
101−
102− Scaffold(snackbarHost = { SnackbarHost(snackbar) }) { padding ->
103− AnimatedContent(
104− targetState = state.screen,
105− modifier = Modifier.fillMaxSize().padding(padding),
106− transitionSpec = {
107− // Compare screen ordinals to decide slide direction: forward navigation
108− // (Connect → Sessions → Chat) slides in from the right, back slides from the left.
109− if (targetState.ordinal > initialState.ordinal) {
110− slideInHorizontally { it } togetherWith slideOutHorizontally { -it }
384+ popEnterTransition = {
385+ if (reducedMotion) {
386+ EnterTransition.None
111387 } else {
112− slideInHorizontally { -it } togetherWith slideOutHorizontally { it }
388+ fadeIn(tween(NetworkConfig.motionFadeDurationMs))
113389 }
114390 },
115− label = "screen",
116− ) { screen ->
117− when (screen) {
118− Screen.Connect -> ConnectScreen(state, dispatch)
119− Screen.Sessions -> SessionsScreen(state, dispatch)
120− Screen.Chat -> ChatScreen(state, dispatch)
391+ popExitTransition = {
392+ if (reducedMotion) {
393+ ExitTransition.None
394+ } else {
395+ slideOutHorizontally(animationSpec = tween(NetworkConfig.motionSlideDurationMs)) { it * slideDir } +
396+ fadeOut(tween(NetworkConfig.motionFadeDurationMs))
397+ }
398+ },
399+ ) {
400+
401+ composable(Routes.SERVERS) {
402+ ServerListScreen(
403+ container = container,
404+ onConnected = {
405+ // Only leave the server list if we're still on it. A pending deep-link /
406+ // notification open sets activeConnection (which drives the open-session
407+ // effect: SERVERS -> SESSIONS -> chat) *before* autoConnectDone fires this
408+ // callback, so a blind navigate(SESSIONS) here would slam a second SESSIONS
409+ // on top of the chat it just opened, burying it. Order-independent: if the
410+ // deep link already navigated away from SERVERS, this is a no-op.
411+ if (navController.currentDestination?.route == Routes.SERVERS) {
412+ navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
413+ }
414+ },
415+ onAddProfile = { navController.navigate(Routes.SERVER_EDIT) },
416+ onServerSettings = { id -> navController.navigate(Routes.serverSettings(id)) },
417+ )
418+ }
419+
420+ composable(Routes.SERVER_EDIT) {
421+ ServerEditScreen(
422+ container = container,
423+ onDone = { navController.popBackStack() },
424+ )
425+ }
426+
427+ composable(
428+ route = "${Routes.SERVER_SETTINGS}/{id}",
429+ arguments = listOf(navArgument("id") { type = NavType.StringType }),
430+ ) { entry ->
431+ ServerSettingsScreen(
432+ container = container,
433+ profileId = entry.arguments?.getString("id").orEmpty(),
434+ onDone = { navController.popBackStack() },
435+ )
436+ }
437+
438+ composable(Routes.SESSIONS) {
439+ if (isTwoPane) {
440+ TwoPaneSessionChat(
441+ container = container,
442+ onOpenFiles = { navController.navigate(Routes.FILES) },
443+ onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
444+ onOpenSettings = { navController.navigate(Routes.SETTINGS) },
445+ onOpenSearch = { navController.navigate(Routes.SEARCH) },
446+ onDisconnect = {
447+ scope.launch { runCatchingCancellable { container.disconnect() } }
448+ navController.popBackStack(Routes.SERVERS, inclusive = false)
449+ },
450+ onAddServer = { navController.navigate(Routes.SERVER_EDIT) },
451+ onEditProfile = { id -> navController.navigate(Routes.serverSettings(id)) },
452+ )
453+ } else {
454+ SessionListScreen(
455+ container = container,
456+ onOpenSession = { id ->
457+ // Set the draft synchronously in-memory before navigating so the
458+ // ChatScreen sees it on first composition. The async persistence
459+ // to disk happens in the background via draftStore.set.
460+ container.consumePendingShare()?.let { shareText ->
461+ container.draftStore.setImmediate(id, shareText)
462+ scope.launch { runCatchingCancellable { container.draftStore.set(id, shareText) } }
463+ }
464+ navController.navigate(Routes.chat(id))
465+ },
466+ onDisconnect = {
467+ scope.launch { runCatchingCancellable { container.disconnect() } }
468+ navController.popBackStack(Routes.SERVERS, inclusive = false)
469+ },
470+ onOpenFiles = { navController.navigate(Routes.FILES) },
471+ onOpenSettings = { navController.navigate(Routes.SETTINGS) },
472+ onOpenSearch = { navController.navigate(Routes.SEARCH) },
473+ onAddServer = { navController.navigate(Routes.SERVER_EDIT) },
474+ onEditProfile = { id -> navController.navigate(Routes.serverSettings(id)) },
475+ )
476+ }
477+ }
478+
479+ composable(
480+ route = "${Routes.CHAT}/{sessionId}?focus={focus}",
481+ arguments = listOf(
482+ navArgument("sessionId") { type = NavType.StringType },
483+ navArgument("focus") { type = NavType.StringType; nullable = true; defaultValue = null },
484+ ),
485+ ) { entry ->
486+ ChatScreen(
487+ container = container,
488+ sessionId = entry.arguments?.getString("sessionId").orEmpty(),
489+ focusMessageId = entry.arguments?.getString("focus"),
490+ onBack = { navController.popBackStack() },
491+ onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
492+ onOpenSession = { id ->
493+ navController.navigate(Routes.chat(id)) {
494+ // Prevent the same chat from being pushed twice when re-tapping a
495+ // session deep link, and keep the back stack bounded when chaining
496+ // chat → linked chat → … (otherwise every hop piles a new entry).
497+ launchSingleTop = true
498+ }
499+ },
500+ onEditProfile = { id -> navController.navigate(Routes.serverSettings(id)) },
501+ )
502+ }
503+
504+ composable(Routes.FILES) {
505+ if (isTwoPane) {
506+ TwoPaneFileBrowser(
507+ container = container,
508+ onBack = { navController.popBackStack() },
509+ )
510+ } else {
511+ FileBrowserScreen(
512+ container = container,
513+ onOpenFile = { path, line -> navController.navigate(Routes.fileView(path, line)) },
514+ onBack = { navController.popBackStack() },
515+ )
516+ }
517+ }
518+
519+ composable(
520+ route = "${Routes.FILE_VIEW}?path={path}&line={line}",
521+ arguments = listOf(
522+ navArgument("path") { type = NavType.StringType; defaultValue = "" },
523+ navArgument("line") { type = NavType.IntType; defaultValue = -1 },
524+ ),
525+ ) { entry ->
526+ val line = entry.arguments?.getInt("line") ?: -1
527+ FileViewScreen(
528+ container = container,
529+ path = entry.arguments?.getString("path").orEmpty(),
530+ initialLine = line.takeIf { it > 0 },
531+ onBack = { navController.popBackStack() },
532+ )
533+ }
534+
535+ composable(Routes.SETTINGS) {
536+ SettingsScreen(
537+ container = container,
538+ onBack = { navController.popBackStack() },
539+ // Navigate to the server list as a new entry on top of Sessions so the
540+ // user can back out to their conversations. Previously this popped back
541+ // to the root SERVERS destination, which removed Sessions from the stack
542+ // and left the user unable to return to their conversations without
543+ // reconnecting. Pop only Settings, then push SERVERS; back from the
544+ // server list lands on Sessions (the screen the user was on before
545+ // Settings), and connecting pushes a fresh SESSIONS as usual.
546+ onManageServers = {
547+ navController.navigate(Routes.SERVERS) {
548+ popUpTo(Routes.SETTINGS) { inclusive = true }
549+ launchSingleTop = true
550+ }
551+ },
552+ onOpenDiagnostics = { navController.navigate(Routes.DIAGNOSTICS) },
553+ onOpenUsage = { navController.navigate(Routes.USAGE) },
554+ onOpenMcp = { navController.navigate(Routes.MCP) },
555+ onEditProfile = { id -> navController.navigate(Routes.serverSettings(id)) },
556+ )
557+ }
558+
559+ composable(Routes.DIAGNOSTICS) {
560+ DiagnosticsScreen(onBack = { navController.popBackStack() })
561+ }
562+
563+ composable(Routes.USAGE) {
564+ UsageScreen(
565+ container = container,
566+ onBack = { navController.popBackStack() },
567+ onOpenSession = { id -> navController.navigate(Routes.chat(id)) },
568+ )
569+ }
570+
571+ composable(Routes.MCP) {
572+ McpScreen(container = container, onBack = { navController.popBackStack() })
573+ }
574+
575+ composable(Routes.SEARCH) {
576+ GlobalSearchScreen(
577+ container = container,
578+ onOpenSession = { id, focusMessageId -> navController.navigate(Routes.chat(id, focusMessageId)) },
579+ onBack = { navController.popBackStack() },
580+ )
581+ }
582+ }
583+ }
584+
585+ // Collect unread/run state for nav badges (rail on large screens, bottom bar on
586+ // compact screens), so a user on Files/Settings sees pending activity at a glance.
587+ val unread by container.unread.collectAsStateWithLifecycle()
588+ val anyRunActive by container.anyRunActive.collectAsStateWithLifecycle()
589+ val totalUnread = remember(unread) { unread.values.sum() }
590+
591+ if (showNavRail) {
592+ Row(modifier = Modifier.fillMaxSize()) {
593+ LargeScreenNavRail(
594+ currentRoute = currentRoute,
595+ connected = connection != null,
596+ unreadCount = totalUnread,
597+ runActive = anyRunActive,
598+ // Use the standard M3 nav-bar pattern: popUpTo the start destination
599+ // saving state, and restoreState on re-entry, so switching between
600+ // top-level tabs via the rail preserves each tab's scroll/selection
601+ // state and back always exits cleanly instead of popping through
602+ // every sibling tab the user visited.
603+ onNavigate = { route ->
604+ navController.navigate(route) {
605+ popUpTo(navController.graph.findStartDestination().id) {
606+ saveState = true
607+ }
608+ launchSingleTop = true
609+ restoreState = true
610+ }
611+ },
612+ )
613+ Box(modifier = Modifier.weight(1f).fillMaxHeight()) { navHost() }
614+ }
615+ } else {
616+ // Compact screens: a bottom NavigationBar surfaces the top-level destinations one
617+ // tap away (Sessions/Search/Files/Settings/Servers) instead of buried in each
618+ // screen's overflow menu. The navHost fills the space above the bar; the bar's
619+ // own height is accounted for by the NavigationBar's intrinsic measurement.
620+ androidx.compose.foundation.layout.Column(modifier = Modifier.fillMaxSize()) {
621+ Box(modifier = Modifier.weight(1f).fillMaxHeight()) { navHost() }
622+ CompactNavBar(
623+ currentRoute = currentRoute,
624+ connected = connection != null,
625+ unreadCount = totalUnread,
626+ runActive = anyRunActive,
627+ onNavigate = { route ->
628+ navController.navigate(route) {
629+ popUpTo(navController.graph.findStartDestination().id) {
630+ saveState = true
631+ }
632+ launchSingleTop = true
633+ restoreState = true
634+ }
635+ },
636+ )
121637 }
122638 }
123639 }
640+ }
124641}
app/src/main/java/soy/iko/opencode/ui/Routes.ktnew file+36
@@ -0,0 +1,36 @@
1+package soy.iko.opencode.ui
2+
3+/** String routes for the app's NavHost. Kept simple and centralized. */
4+object Routes {
5+ const val SERVERS = "servers"
6+ const val SESSIONS = "sessions"
7+ const val SETTINGS = "settings"
8+ const val DIAGNOSTICS = "diagnostics"
9+ const val USAGE = "usage"
10+ const val MCP = "mcp"
11+ const val FILES = "files"
12+ const val SEARCH = "search"
13+
14+ const val SERVER_EDIT = "server_edit"
15+ const val SERVER_SETTINGS = "server_settings"
16+ fun serverSettings(id: String) = "$SERVER_SETTINGS/${android.net.Uri.encode(id)}"
17+
18+ const val CHAT = "chat"
19+ fun chat(sessionId: String, focusMessageId: String? = null): String {
20+ val base = "$CHAT/${android.net.Uri.encode(sessionId)}"
21+ // An optional focusMessageId scrolls the chat to (and briefly highlights) that message,
22+ // used by global search to jump directly to the matched message instead of the tail.
23+ return if (focusMessageId != null && focusMessageId.isNotBlank()) {
24+ "$base?focus=${android.net.Uri.encode(focusMessageId)}"
25+ } else {
26+ base
27+ }
28+ }
29+
30+ const val FILE_VIEW = "file_view"
31+ fun fileView(path: String, line: Int? = null): String {
32+ val base = "$FILE_VIEW?path=${android.net.Uri.encode(path)}"
33+ // A positive [line] scrolls the viewer to that 1-based line (from a search hit).
34+ return if (line != null && line > 0) "$base&line=$line" else base
35+ }
36+}
app/src/main/java/soy/iko/opencode/ui/VmFactory.ktnew file+16
@@ -0,0 +1,16 @@
1+package soy.iko.opencode.ui
2+
3+import androidx.lifecycle.ViewModel
4+import androidx.lifecycle.viewmodel.CreationExtras
5+import androidx.lifecycle.viewmodel.initializer
6+import androidx.lifecycle.viewmodel.viewModelFactory
7+
8+/**
9+ * Tiny bridge between manual DI and Compose's `viewModel()`. Builds a
10+ * [androidx.lifecycle.ViewModelProvider.Factory] from a plain constructor lambda.
11+ */
12+inline fun <reified VM : ViewModel> vmFactory(
13+ crossinline create: (CreationExtras) -> VM,
14+) = viewModelFactory {
15+ initializer { create(this) }
16+}
app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.ktnew file+318
@@ -0,0 +1,318 @@
1+package soy.iko.opencode.ui.chat
2+
3+import androidx.compose.foundation.clickable
4+import androidx.compose.foundation.layout.Column
5+import androidx.compose.foundation.layout.Row
6+import androidx.compose.foundation.layout.fillMaxWidth
7+import androidx.compose.foundation.layout.heightIn
8+import androidx.compose.foundation.layout.imePadding
9+import androidx.compose.foundation.layout.navigationBarsPadding
10+import androidx.compose.foundation.layout.padding
11+import androidx.compose.foundation.layout.size
12+import androidx.compose.foundation.lazy.LazyColumn
13+import androidx.compose.foundation.lazy.items
14+import androidx.compose.foundation.lazy.rememberLazyListState
15+import androidx.compose.foundation.text.KeyboardActions
16+import androidx.compose.foundation.text.KeyboardOptions
17+import androidx.compose.material.icons.Icons
18+import androidx.compose.material.icons.filled.Check
19+import androidx.compose.material.icons.filled.Close
20+import androidx.compose.material.icons.filled.Search
21+import androidx.compose.material3.CircularProgressIndicator
22+import androidx.compose.material3.ExperimentalMaterial3Api
23+import androidx.compose.material3.Icon
24+import androidx.compose.material3.IconButton
25+import androidx.compose.material3.MaterialTheme
26+import androidx.compose.material3.ModalBottomSheet
27+import androidx.compose.material3.OutlinedTextField
28+import androidx.compose.material3.Text
29+import androidx.compose.material3.TextButton
30+import androidx.compose.material3.rememberModalBottomSheetState
31+import androidx.compose.runtime.Composable
32+import androidx.compose.runtime.LaunchedEffect
33+import androidx.compose.runtime.getValue
34+import androidx.compose.runtime.mutableStateOf
35+import androidx.compose.runtime.remember
36+import androidx.compose.runtime.rememberCoroutineScope
37+import androidx.compose.runtime.saveable.rememberSaveable
38+import androidx.compose.runtime.setValue
39+import androidx.compose.ui.Alignment
40+import androidx.compose.ui.Modifier
41+import androidx.compose.ui.focus.FocusRequester
42+import androidx.compose.ui.focus.focusRequester
43+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
44+import androidx.compose.ui.platform.LocalHapticFeedback
45+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
46+import androidx.compose.ui.res.stringResource
47+import androidx.compose.ui.semantics.Role
48+import androidx.compose.ui.semantics.contentDescription
49+import androidx.compose.ui.semantics.role
50+import androidx.compose.ui.semantics.selectableGroup
51+import androidx.compose.ui.semantics.selected
52+import androidx.compose.ui.semantics.semantics
53+import androidx.compose.ui.text.input.ImeAction
54+import androidx.compose.ui.text.style.TextOverflow
55+import androidx.compose.ui.unit.dp
56+import androidx.compose.foundation.layout.Box
57+import kotlinx.coroutines.launch
58+import soy.iko.opencode.data.model.Agent
59+import soy.iko.opencode.data.network.NetworkConfig
60+import soy.iko.opencode.R
61+
62+/**
63+ * Flat LazyColumn index of the selected agent, offset by the leading "Default" row when it's
64+ * shown. A null selection targets that Default row (index 0). -1 when the selection isn't in
65+ * the current list.
66+ */
67+/** Whether the synthetic "Default" agent row matches the current search query. */
68+private fun defaultAgentMatches(query: String, label: String, desc: String): Boolean =
69+ query.isEmpty() ||
70+ label.contains(query, ignoreCase = true) ||
71+ desc.contains(query, ignoreCase = true)
72+
73+private fun selectedAgentIndex(
74+ distinctAgents: List<Agent>,
75+ selected: String?,
76+ defaultMatches: Boolean,
77+): Int {
78+ if (selected == null) return if (defaultMatches) 0 else -1
79+ val base = if (defaultMatches) 1 else 0
80+ val pos = distinctAgents.indexOfFirst { it.name == selected }
81+ return if (pos >= 0) base + pos else -1
82+}
83+
84+/** Bottom sheet that lists every agent and lets the user pick one. */
85+@OptIn(ExperimentalMaterial3Api::class)
86+@Composable
87+fun AgentPickerSheet(
88+ agents: List<Agent>,
89+ selected: String?,
90+ loading: Boolean,
91+ error: Boolean,
92+ onSelect: (Agent?) -> Unit,
93+ onRetry: () -> Unit,
94+ onDismiss: () -> Unit,
95+) {
96+ // Hoist the SheetState so selection can animate the sheet out (slide) instead of
97+ // snap-dismissing by tearing down the composition. skipPartiallyExpanded = true so the
98+ // sheet opens at full height and the list's heightIn cap resolves against a stable sheet,
99+ // not the half-height partial state (which previously left the list at ~25% of the screen
100+ // until the user dragged up).
101+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
102+ val scope = rememberCoroutineScope()
103+ ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
104+ Text(
105+ stringResource(R.string.agent),
106+ style = MaterialTheme.typography.titleMedium,
107+ modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
108+ )
109+ if (loading) {
110+ val loadingLabel = stringResource(R.string.loading_agents)
111+ Column(
112+ modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp).imePadding(),
113+ horizontalAlignment = Alignment.CenterHorizontally,
114+ ) {
115+ CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
116+ Text(
117+ stringResource(R.string.loading_agents),
118+ style = MaterialTheme.typography.bodyMedium,
119+ color = MaterialTheme.colorScheme.onSurfaceVariant,
120+ modifier = Modifier.padding(top = 8.dp),
121+ )
122+ }
123+ } else if (error && agents.isEmpty()) {
124+ Column(
125+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
126+ horizontalAlignment = Alignment.CenterHorizontally,
127+ ) {
128+ Text(
129+ stringResource(R.string.load_failed),
130+ style = MaterialTheme.typography.bodyMedium,
131+ color = MaterialTheme.colorScheme.error,
132+ )
133+ TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
134+ }
135+ } else if (agents.isEmpty()) {
136+ Text(
137+ stringResource(R.string.no_agents),
138+ style = MaterialTheme.typography.bodyMedium,
139+ color = MaterialTheme.colorScheme.onSurfaceVariant,
140+ modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
141+ )
142+ } else {
143+ var query by rememberSaveable { mutableStateOf("") }
144+ val keyboardController = LocalSoftwareKeyboardController.current
145+ val haptics = LocalHapticFeedback.current
146+ // Auto-focus the search field (and raise the keyboard) when the sheet opens, but skip
147+ // it for a short catalog that's faster to scan by eye than to filter.
148+ val searchFocus = remember { FocusRequester() }
149+ LaunchedEffect(Unit) {
150+ if (agents.size > NetworkConfig.pickerSearchAutofocusThreshold) {
151+ runCatching { searchFocus.requestFocus() }
152+ }
153+ }
154+ OutlinedTextField(
155+ value = query,
156+ onValueChange = { query = it },
157+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 8.dp).focusRequester(searchFocus),
158+ placeholder = { Text(stringResource(R.string.search_agents)) },
159+ leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
160+ trailingIcon = if (query.isNotEmpty()) {
161+ {
162+ IconButton(onClick = { query = "" }) {
163+ Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
164+ }
165+ }
166+ } else null,
167+ singleLine = true,
168+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
169+ keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
170+ )
171+ // Dedupe the source list once (keyed on `agents`, not the per-keystroke `filtered`),
172+ // so distinctBy doesn't re-walk the catalog on every character typed.
173+ val distinctAgents = remember(agents) { agents.distinctBy { it.name } }
174+ val filtered = remember(distinctAgents, query) {
175+ val q = query.trim()
176+ if (q.isEmpty()) distinctAgents
177+ else distinctAgents.filter {
178+ it.name.contains(q, ignoreCase = true) ||
179+ it.displayDescription.contains(q, ignoreCase = true)
180+ }
181+ }
182+ // The "Default" option participates in the search too: show it when the query is
183+ // empty or matches its label/description. Without this it either always showed
184+ // (ignoring the filter) or, when a query matched no named agent, disappeared with
185+ // the whole list — leaving the default unselectable.
186+ val defaultLabel = stringResource(R.string.default_agent)
187+ val defaultDesc = stringResource(R.string.default_agent_desc)
188+ val q = query.trim()
189+ val defaultMatches = defaultAgentMatches(q, defaultLabel, defaultDesc)
190+ if (filtered.isEmpty() && !defaultMatches) {
191+ Text(
192+ stringResource(R.string.no_agents_match, query.trim()),
193+ style = MaterialTheme.typography.bodyMedium,
194+ color = MaterialTheme.colorScheme.onSurfaceVariant,
195+ modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
196+ )
197+ } else {
198+ val listState = rememberLazyListState()
199+ // Flat index of the selected agent, offset by the leading "Default" row when
200+ // it's shown. selected == null targets that Default row (index 0). -1 when the
201+ // selection isn't in the current list. Computed against `filtered` (not the
202+ // unfiltered distinctAgents) so a query that hides agents before the selection
203+ // still maps to the right visible row.
204+ val selectedIndex = remember(filtered, selected, defaultMatches) {
205+ selectedAgentIndex(filtered, selected, defaultMatches)
206+ }
207+ // Re-scroll to the selected item whenever the filter changes (not just once on
208+ // open), so clearing a filter that had hidden the selection brings it back into
209+ // view instead of leaving the list at the top.
210+ LaunchedEffect(filtered, defaultMatches) {
211+ if (selectedIndex >= 0) listState.scrollToItem(selectedIndex)
212+ }
213+ // Cap the list height against the screen, not the (partial) sheet, so the list
214+ // is a predictable height regardless of drag position. weight(1f) would also
215+ // work but heightIn keeps the sheet compact for short catalogs.
216+ LazyColumn(
217+ state = listState,
218+ modifier = Modifier
219+ .heightIn(max = NetworkConfig.pickerSheetMaxHeightDp.dp)
220+ .imePadding()
221+ .navigationBarsPadding()
222+ .semantics { selectableGroup() },
223+ ) {
224+ if (defaultMatches) {
225+ item(key = "__default") {
226+ Column(
227+ modifier = Modifier
228+ .fillMaxWidth()
229+ .semantics(mergeDescendants = true) {
230+ this.selected = (selected == null)
231+ }
232+ .clickable(role = Role.RadioButton) {
233+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
234+ // Animate the sheet out before tearing down, so the selection
235+ // reads as a smooth transition instead of a snap disappearance.
236+ scope.launch { sheetState.hide() }.invokeOnCompletion { onDismiss() }
237+ onSelect(null)
238+ }
239+ .padding(horizontal = 24.dp, vertical = 12.dp),
240+ ) {
241+ Row(verticalAlignment = Alignment.CenterVertically) {
242+ Text(
243+ stringResource(R.string.default_agent),
244+ style = MaterialTheme.typography.bodyLarge,
245+ color = if (selected == null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
246+ modifier = Modifier.weight(1f),
247+ )
248+ if (selected == null) {
249+ Icon(
250+ Icons.Filled.Check,
251+ contentDescription = null,
252+ tint = MaterialTheme.colorScheme.primary,
253+ modifier = Modifier.size(20.dp),
254+ )
255+ }
256+ }
257+ Text(
258+ stringResource(R.string.default_agent_desc),
259+ style = MaterialTheme.typography.bodySmall,
260+ color = MaterialTheme.colorScheme.onSurfaceVariant,
261+ )
262+ }
263+ }
264+ }
265+ // distinctBy the name so a server that returns two agents with the same name
266+ // (e.g. a built-in colliding with a project-level one) can't crash the list
267+ // with LazyColumn's "Key was already used".
268+ // Namespace the agent keys so an agent literally named "__default" can't
269+ // collide with the sentinel key of the default item above (a duplicate key
270+ // crashes LazyColumn).
271+ items(filtered, key = { "agent_" + it.name }) { agent ->
272+ val isSelected = agent.name == selected
273+ Column(
274+ modifier = Modifier
275+ .fillMaxWidth()
276+ .semantics(mergeDescendants = true) {
277+ this.selected = isSelected
278+ }
279+ .clickable(role = Role.RadioButton) {
280+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
281+ scope.launch { sheetState.hide() }.invokeOnCompletion { onDismiss() }
282+ onSelect(agent)
283+ }
284+ .padding(horizontal = 24.dp, vertical = 12.dp),
285+ ) {
286+ Row(verticalAlignment = Alignment.CenterVertically) {
287+ Text(
288+ agent.name,
289+ style = MaterialTheme.typography.bodyLarge,
290+ color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
291+ modifier = Modifier.weight(1f),
292+ )
293+ if (isSelected) {
294+ Icon(
295+ Icons.Filled.Check,
296+ contentDescription = null,
297+ tint = MaterialTheme.colorScheme.primary,
298+ modifier = Modifier.size(20.dp),
299+ )
300+ }
301+ }
302+ Text(
303+ agent.displayDescription,
304+ style = MaterialTheme.typography.bodySmall,
305+ color = MaterialTheme.colorScheme.onSurfaceVariant,
306+ // Cap at 2 lines (matching the command picker) so a verbose
307+ // description doesn't make the row arbitrarily tall and push the
308+ // radio affordance out of alignment across the list.
309+ maxLines = 2,
310+ overflow = TextOverflow.Ellipsis,
311+ )
312+ }
313+ }
314+ }
315+ }
316+ }
317+ }
318+}
app/src/main/java/soy/iko/opencode/ui/chat/AttachmentResult.ktnew file+12
@@ -0,0 +1,12 @@
1+package soy.iko.opencode.ui.chat
2+
3+/** Outcome of turning a picked Uri into a [PendingAttachment] (see `Uri.toAttachmentResult`). */
4+sealed interface AttachmentResult {
5+ data class Ok(val attachment: PendingAttachment) : AttachmentResult
6+
7+ /** The file exceeded the size cap ([soy.iko.opencode.data.network.NetworkConfig.maxAttachmentBytes]). */
8+ data object TooLarge : AttachmentResult
9+
10+ /** The file couldn't be read/resolved. */
11+ data object Failed : AttachmentResult
12+}
app/src/main/java/soy/iko/opencode/ui/chat/Attachments.ktnew file+117
@@ -0,0 +1,117 @@
1+package soy.iko.opencode.ui.chat
2+
3+import android.content.Context
4+import android.net.Uri
5+import android.provider.OpenableColumns
6+import android.util.Base64
7+import androidx.core.content.FileProvider
8+import soy.iko.opencode.data.model.FilePromptPart
9+import soy.iko.opencode.data.network.NetworkConfig
10+import soy.iko.opencode.util.runCatchingCancellable
11+import kotlinx.coroutines.Dispatchers
12+import kotlinx.coroutines.withContext
13+import java.io.File
14+
15+/**
16+ * Read [uri]'s bytes off the main thread, base64-encode them into a `data:` URL, and wrap
17+ * the result as a [PendingAttachment] ready to send. Enforces the size cap. The original
18+ * [uri] is kept as the thumbnail model for images (Coil loads content Uris directly).
19+ */
20+suspend fun Uri.toAttachmentResult(context: Context): AttachmentResult = withContext(Dispatchers.IO) {
21+ val resolver = context.contentResolver
22+ val mime = resolver.getType(this@toAttachmentResult) ?: "application/octet-stream"
23+ val name = displayName(context) ?: defaultName(mime)
24+ val max = NetworkConfig.maxAttachmentBytes
25+ // Cheap pre-check: if the provider reports a size, reject an oversize file before reading
26+ // a single byte into memory. The picker can hand back arbitrarily large files (e.g. a
27+ // multi-hundred-MB video), so reading first and checking after would OOM the app.
28+ val declaredSize = fileSize(context)
29+ if (declaredSize != null && declaredSize > max) return@withContext AttachmentResult.TooLarge
30+ // Read with a hard cap so a provider that under-reports or omits its size still can't OOM
31+ // us: stop as soon as we've read one byte past the cap, then reject.
32+ val bytes = runCatchingCancellable {
33+ resolver.openInputStream(this@toAttachmentResult)?.use { it.readAtMost(max + 1) }
34+ }.getOrNull() ?: return@withContext AttachmentResult.Failed
35+ if (bytes.size > max) return@withContext AttachmentResult.TooLarge
36+ val b64 = Base64.encodeToString(bytes, Base64.NO_WRAP)
37+ val dataUrl = "data:$mime;base64,$b64"
38+ AttachmentResult.Ok(
39+ PendingAttachment(
40+ id = java.util.UUID.randomUUID().toString(),
41+ name = name,
42+ mime = mime,
43+ previewModel = if (mime.startsWith("image/")) this@toAttachmentResult.toString() else null,
44+ part = FilePromptPart(mime = mime, url = dataUrl, filename = name),
45+ ),
46+ )
47+}
48+
49+/** The provider-reported byte size of the content [uri], or null if it isn't exposed. */
50+private fun Uri.fileSize(context: Context): Long? = runCatching {
51+ context.contentResolver.query(this, arrayOf(OpenableColumns.SIZE), null, null, null)?.use { cursor ->
52+ val idx = cursor.getColumnIndex(OpenableColumns.SIZE)
53+ if (idx >= 0 && cursor.moveToFirst() && !cursor.isNull(idx)) cursor.getLong(idx) else null
54+ }
55+}.getOrNull()
56+
57+/** Read up to [limit] bytes from the stream without materializing the whole file, so an
58+ * oversize source can be rejected on a bounded amount of memory. Reaching [limit] bytes is
59+ * the caller's signal that the source exceeds the cap. */
60+private fun java.io.InputStream.readAtMost(limit: Long): ByteArray {
61+ val out = java.io.ByteArrayOutputStream()
62+ val chunk = ByteArray(8 * 1024)
63+ var total = 0L
64+ while (total < limit) {
65+ val want = minOf(chunk.size.toLong(), limit - total).toInt()
66+ val read = read(chunk, 0, want)
67+ if (read < 0) break
68+ out.write(chunk, 0, read)
69+ total += read
70+ }
71+ return out.toByteArray()
72+}
73+
74+/** The raw (pre-base64) byte size encoded in a `data:<mime>;base64,<payload>` URL, derived from
75+ * the payload length (len / 4 * 3, less any '=' padding) without allocating or decoding the
76+ * payload — important since the payload can be many MB. Returns 0 when the URL has no base64
77+ * payload. Lets the cumulative attachment-size cap use the same raw-byte measure as the per-file
78+ * [NetworkConfig.maxAttachmentBytes] check above (which counts raw `bytes.size`). */
79+internal fun base64DataUrlByteSize(dataUrl: String): Long {
80+ val marker = "base64,"
81+ val start = dataUrl.indexOf(marker)
82+ if (start < 0) return 0L
83+ val payloadLen = dataUrl.length - (start + marker.length)
84+ if (payloadLen <= 0) return 0L
85+ val padding = when {
86+ dataUrl.endsWith("==") -> 2
87+ dataUrl.endsWith("=") -> 1
88+ else -> 0
89+ }
90+ return payloadLen.toLong() / 4 * 3 - padding
91+}
92+
93+/** Resolve a human-readable display name for the content [uri], if the provider exposes one. */
94+private fun Uri.displayName(context: Context): String? = runCatching {
95+ context.contentResolver.query(this, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
96+ val idx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
97+ if (idx >= 0 && cursor.moveToFirst()) cursor.getString(idx) else null
98+ }
99+}.getOrNull()?.takeIf { it.isNotBlank() }
100+
101+private fun defaultName(mime: String): String {
102+ val ext = mime.substringAfterLast('/', "bin").takeIf { it.isNotBlank() } ?: "bin"
103+ return "attachment.$ext"
104+}
105+
106+/**
107+ * Create a temp file in the cache and a shareable content [Uri] for it (via the app's
108+ * [FileProvider]) to hand to the camera as the capture target. Returns null if it can't be
109+ * created. Uses a fixed filename so each capture overwrites the prior (the OS also evicts
110+ * cache under storage pressure); a unique-per-capture timestamped name would leak a .jpg
111+ * per canceled capture in cacheDir/captures/ with no cleanup path.
112+ */
113+fun newCameraCaptureUri(context: Context): Uri? = runCatching {
114+ val dir = File(context.cacheDir, "captures").apply { mkdirs() }
115+ val file = File(dir, "capture.jpg")
116+ FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
117+}.getOrNull()
app/src/main/java/soy/iko/opencode/ui/chat/ChatAttachments.ktnew file+258
@@ -0,0 +1,258 @@
1+package soy.iko.opencode.ui.chat
2+
3+import androidx.compose.foundation.layout.Arrangement
4+import androidx.compose.foundation.layout.Box
5+import androidx.compose.foundation.layout.Column
6+import androidx.compose.foundation.layout.Row
7+import androidx.compose.foundation.layout.fillMaxSize
8+import androidx.compose.foundation.layout.fillMaxWidth
9+import androidx.compose.foundation.layout.padding
10+import androidx.compose.foundation.layout.size
11+import androidx.compose.foundation.layout.widthIn
12+import androidx.compose.foundation.clickable
13+import androidx.compose.foundation.combinedClickable
14+import androidx.compose.foundation.horizontalScroll
15+import androidx.compose.foundation.rememberScrollState
16+import androidx.compose.material.icons.Icons
17+import androidx.compose.material.icons.filled.Close
18+import androidx.compose.material.icons.filled.Description
19+import androidx.compose.material3.AlertDialog
20+import androidx.compose.material3.CircularProgressIndicator
21+import androidx.compose.material3.Icon
22+import androidx.compose.material3.IconButton
23+import androidx.compose.material3.MaterialTheme
24+import androidx.compose.material3.Surface
25+import androidx.compose.material3.Text
26+import androidx.compose.runtime.Composable
27+import androidx.compose.runtime.getValue
28+import androidx.compose.runtime.mutableStateOf
29+import androidx.compose.runtime.remember
30+import androidx.compose.runtime.setValue
31+import androidx.compose.ui.Alignment
32+import androidx.compose.ui.Modifier
33+import androidx.compose.ui.draw.clip
34+import androidx.compose.ui.layout.ContentScale
35+import androidx.compose.ui.res.stringResource
36+import androidx.compose.ui.semantics.Role
37+import androidx.compose.ui.semantics.contentDescription
38+import androidx.compose.ui.semantics.semantics
39+import androidx.compose.ui.text.style.TextOverflow
40+import androidx.compose.ui.unit.dp
41+import androidx.compose.ui.window.Dialog
42+import androidx.compose.ui.window.DialogProperties
43+import coil3.compose.AsyncImage
44+import soy.iko.opencode.R
45+import soy.iko.opencode.data.network.NetworkConfig
46+import java.util.Locale
47+
48+/**
49+ * The staged-attachment UI for the chat composer — extracted from ChatScreen so that (large)
50+ * file stays focused on the conversation surface. `internal` so ChatScreen can call it.
51+ *
52+ * When at least one attachment is staged, a muted total-size label is rendered at the end of
53+ * the strip so the user can see how close they are to the cumulative [NetworkConfig.maxTotalAttachmentBytes]
54+ * cap — a bare per-file cap doesn't surface the combined total, and a failed send is the only
55+ * other feedback.
56+ */
57+@Composable
58+internal fun AttachmentStrip(
59+ attachments: List<PendingAttachment>,
60+ onRemove: (String) -> Unit,
61+ staging: Boolean = false,
62+ stagingFileCount: Int = 0,
63+) {
64+ // Show the strip while an encode is in flight even before the first chip exists, so the
65+ // user gets immediate feedback that their pick is being processed.
66+ if (attachments.isEmpty() && !staging) return
67+ // Horizontally-scrollable strip of staged attachment thumbnails/chips, each removable.
68+ Row(
69+ modifier = Modifier
70+ .fillMaxWidth()
71+ .horizontalScroll(rememberScrollState())
72+ .padding(start = 8.dp, end = 8.dp, top = 8.dp),
73+ horizontalArrangement = Arrangement.spacedBy(8.dp),
74+ verticalAlignment = Alignment.CenterVertically,
75+ ) {
76+ attachments.forEach { att ->
77+ AttachmentChip(att, onRemove = { onRemove(att.id) })
78+ }
79+ // A placeholder chip with an indeterminate spinner while any pick is still being
80+ // read + base64-encoded off the main thread (chips only materialize once done).
81+ if (staging) StagingChip(stagingFileCount)
82+ // Total staged size label — surfaced so the user can see how close they are to the
83+ // cumulative cap before a send fails. Hidden while staging (the total is in flux).
84+ if (!staging && attachments.isNotEmpty()) {
85+ val totalBytes = remember(attachments) {
86+ attachments.sumOf { base64DataUrlByteSize(it.part.url) }
87+ }
88+ val maxBytes = NetworkConfig.maxTotalAttachmentBytes
89+ val label = remember(totalBytes, maxBytes) { formatAttachmentTotal(totalBytes, maxBytes) }
90+ val over = totalBytes > maxBytes
91+ Text(
92+ label,
93+ style = MaterialTheme.typography.labelSmall,
94+ color = if (over) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
95+ modifier = Modifier.padding(start = 4.dp, end = 4.dp),
96+ )
97+ }
98+ }
99+}
100+
101+/** Format the staged-attachment total as "N.N MB / max" (or KB when small), for the strip label. */
102+private fun formatAttachmentTotal(bytes: Long, max: Long): String {
103+ val mb = 1024.0 * 1024.0
104+ val kb = 1024.0
105+ val used = when {
106+ bytes >= mb -> String.format(Locale.US, "%.1f MB", bytes / mb)
107+ bytes >= kb -> String.format(Locale.US, "%.0f KB", bytes / kb)
108+ else -> "$bytes B"
109+ }
110+ val maxStr = when {
111+ max >= mb -> String.format(Locale.US, "%.0f MB", max / mb)
112+ max >= kb -> String.format(Locale.US, "%.0f KB", max / kb)
113+ else -> "$max B"
114+ }
115+ return "$used / $maxStr"
116+}
117+
118+/** A placeholder chip shown while an attachment is being encoded. */
119+@Composable
120+private fun StagingChip(stagingFileCount: Int) {
121+ // Show "Staging N files…" when a multi-file count is known; fall back to the generic
122+ // "Attaching…" for the single/unknown case so the label never reads "Staging 1 files".
123+ val label = if (stagingFileCount > 1) {
124+ stringResource(R.string.staging_n_files, stagingFileCount)
125+ } else {
126+ stringResource(R.string.attachment_staging)
127+ }
128+ Surface(
129+ shape = MaterialTheme.shapes.small,
130+ color = MaterialTheme.colorScheme.surfaceVariant,
131+ tonalElevation = 1.dp,
132+ ) {
133+ Row(
134+ verticalAlignment = Alignment.CenterVertically,
135+ modifier = Modifier.padding(horizontal = 10.dp),
136+ ) {
137+ CircularProgressIndicator(
138+ Modifier.size(18.dp).semantics { contentDescription = label },
139+ strokeWidth = 2.dp,
140+ )
141+ Text(
142+ label,
143+ style = MaterialTheme.typography.labelMedium,
144+ maxLines = 1,
145+ modifier = Modifier.padding(start = 8.dp, top = 10.dp, bottom = 10.dp),
146+ )
147+ }
148+ }
149+}
150+
151+/** A single staged attachment: an image thumbnail (or a generic file icon) with its name
152+ * and a remove button. Tapping the thumbnail/name of an image attachment opens a fullscreen
153+ * preview so the user can verify it before sending (a sent image costs tokens and can't be
154+ * unsent). */
155+@Composable
156+private fun AttachmentChip(attachment: PendingAttachment, onRemove: () -> Unit) {
157+ var showPreview by remember { mutableStateOf(false) }
158+ var showNameDialog by remember { mutableStateOf(false) }
159+ val previewLabel = stringResource(R.string.preview_attachment)
160+ val haptics = androidx.compose.ui.platform.LocalHapticFeedback.current
161+ Surface(
162+ shape = MaterialTheme.shapes.small,
163+ color = MaterialTheme.colorScheme.surfaceVariant,
164+ tonalElevation = 1.dp,
165+ ) {
166+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 6.dp)) {
167+ if (attachment.previewModel != null) {
168+ AsyncImage(
169+ model = attachment.previewModel,
170+ contentDescription = attachment.name,
171+ contentScale = ContentScale.Crop,
172+ modifier = Modifier
173+ .size(36.dp)
174+ .clip(MaterialTheme.shapes.extraSmall)
175+ .semantics { contentDescription = previewLabel }
176+ .clickable(role = Role.Image) { showPreview = true },
177+ )
178+ } else {
179+ // Long-press the file icon to see the full filename (it's truncated to 120dp
180+ // in the chip, so a long name can't be read in full otherwise). Mirrors the
181+ // image chip's tap-to-preview pattern for non-image files.
182+ Icon(
183+ Icons.Filled.Description,
184+ contentDescription = null,
185+ modifier = Modifier
186+ .size(24.dp)
187+ .combinedClickable(
188+ role = Role.Image,
189+ onLongClick = {
190+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
191+ showNameDialog = true
192+ },
193+ onClick = {},
194+ ),
195+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
196+ )
197+ }
198+ Text(
199+ attachment.name,
200+ style = MaterialTheme.typography.labelMedium,
201+ maxLines = 1,
202+ overflow = TextOverflow.Ellipsis,
203+ modifier = Modifier.widthIn(max = 120.dp).padding(horizontal = 6.dp),
204+ )
205+ // No .size() override so the IconButton keeps its default 48dp touch target
206+ // (a11y minimum) while the visual X stays small (16dp).
207+ IconButton(onClick = onRemove) {
208+ Icon(
209+ Icons.Filled.Close,
210+ contentDescription = stringResource(R.string.remove),
211+ modifier = Modifier.size(16.dp),
212+ )
213+ }
214+ }
215+ }
216+ if (showPreview && attachment.previewModel != null) {
217+ Dialog(
218+ onDismissRequest = { showPreview = false },
219+ properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
220+ ) {
221+ Box(
222+ modifier = Modifier.fillMaxSize().padding(16.dp),
223+ contentAlignment = Alignment.Center,
224+ ) {
225+ AsyncImage(
226+ model = attachment.previewModel,
227+ contentDescription = attachment.name,
228+ contentScale = ContentScale.Fit,
229+ modifier = Modifier.fillMaxWidth().clickable(role = Role.Image) { showPreview = false },
230+ )
231+ IconButton(
232+ onClick = { showPreview = false },
233+ modifier = Modifier.align(Alignment.TopEnd),
234+ ) {
235+ Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.close))
236+ }
237+ }
238+ }
239+ }
240+ if (showNameDialog) {
241+ AlertDialog(
242+ onDismissRequest = { showNameDialog = false },
243+ title = { Text(stringResource(R.string.attachment_name)) },
244+ text = {
245+ Text(
246+ attachment.name,
247+ style = MaterialTheme.typography.bodyMedium,
248+ fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
249+ )
250+ },
251+ confirmButton = {
252+ androidx.compose.material3.TextButton(onClick = { showNameDialog = false }) {
253+ Text(stringResource(R.string.close))
254+ }
255+ },
256+ )
257+ }
258+}
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.ktnew file+3432
@@ -0,0 +1,3432 @@
1+@file:Suppress("TooManyFunctions")
2+
3+package soy.iko.opencode.ui.chat
4+
5+import android.app.Activity
6+import android.content.Intent
7+import android.net.Uri
8+import android.speech.RecognizerIntent
9+import androidx.activity.compose.BackHandler
10+import androidx.activity.compose.rememberLauncherForActivityResult
11+import androidx.activity.result.PickVisualMediaRequest
12+import androidx.activity.result.contract.ActivityResultContracts
13+import androidx.compose.animation.AnimatedVisibility
14+import androidx.compose.animation.core.RepeatMode
15+import androidx.compose.animation.core.animateFloat
16+import androidx.compose.animation.core.infiniteRepeatable
17+import androidx.compose.animation.core.rememberInfiniteTransition
18+import androidx.compose.animation.core.tween
19+import androidx.compose.foundation.background
20+import androidx.compose.foundation.clickable
21+import androidx.compose.foundation.content.contentReceiver
22+import androidx.compose.foundation.layout.Arrangement
23+import androidx.compose.foundation.layout.Box
24+import androidx.compose.foundation.layout.Column
25+import androidx.compose.foundation.layout.FlowRow
26+import androidx.compose.foundation.layout.PaddingValues
27+import androidx.compose.foundation.layout.Row
28+import androidx.compose.foundation.layout.Spacer
29+import androidx.compose.foundation.layout.WindowInsets
30+import androidx.compose.foundation.layout.fillMaxSize
31+import androidx.compose.foundation.layout.fillMaxWidth
32+import androidx.compose.foundation.layout.heightIn
33+import androidx.compose.foundation.layout.height
34+import androidx.compose.foundation.layout.imePadding
35+import androidx.compose.foundation.layout.ime
36+import androidx.compose.foundation.layout.navigationBars
37+import androidx.compose.foundation.layout.padding
38+import androidx.compose.foundation.layout.size
39+import androidx.compose.foundation.layout.union
40+import androidx.compose.foundation.layout.widthIn
41+import androidx.compose.foundation.layout.windowInsetsPadding
42+import androidx.compose.foundation.layout.wrapContentWidth
43+import androidx.compose.foundation.lazy.LazyColumn
44+import androidx.compose.foundation.lazy.items
45+import androidx.compose.foundation.lazy.rememberLazyListState
46+import androidx.compose.foundation.shape.RoundedCornerShape
47+import androidx.compose.material.icons.Icons
48+import androidx.compose.material.icons.automirrored.filled.ArrowBack
49+import androidx.compose.material.icons.automirrored.filled.Send
50+import androidx.compose.material.icons.filled.ErrorOutline
51+import androidx.compose.material.icons.filled.Add
52+import androidx.compose.material.icons.filled.AutoAwesome
53+import androidx.compose.material.icons.filled.Delete
54+import androidx.compose.material.icons.filled.Description
55+import androidx.compose.material.icons.filled.Close
56+import androidx.compose.material.icons.filled.Edit
57+import androidx.compose.material.icons.filled.Expand
58+import androidx.compose.material.icons.filled.FormatQuote
59+import androidx.compose.material.icons.filled.ExpandLess
60+import androidx.compose.material.icons.filled.ExpandMore
61+import androidx.compose.material.icons.filled.Search
62+import androidx.compose.material.icons.filled.KeyboardArrowDown
63+import androidx.compose.material.icons.filled.KeyboardArrowUp
64+import androidx.compose.material.icons.filled.Mic
65+import androidx.compose.material.icons.filled.MoreVert
66+import androidx.compose.material.icons.filled.PhotoCamera
67+import androidx.compose.material.icons.filled.PhotoLibrary
68+import androidx.compose.material.icons.filled.Refresh
69+import androidx.compose.material.icons.filled.Restore
70+import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
71+import androidx.compose.material.icons.automirrored.filled.Reply
72+import androidx.compose.material.icons.filled.Schedule
73+import androidx.compose.material.icons.filled.Stop
74+import androidx.compose.material3.AlertDialog
75+import androidx.compose.material3.Badge
76+import androidx.compose.material3.BadgedBox
77+import androidx.compose.material3.Button
78+import androidx.compose.material3.CircularProgressIndicator
79+import androidx.compose.material3.SmallFloatingActionButton
80+import androidx.compose.material3.DropdownMenu
81+import androidx.compose.material3.DropdownMenuItem
82+import androidx.compose.material3.ExperimentalMaterial3Api
83+import androidx.compose.material3.ExtendedFloatingActionButton
84+import androidx.compose.material3.FloatingActionButton
85+import androidx.compose.material3.Icon
86+import androidx.compose.material3.IconButton
87+import androidx.compose.material3.MaterialTheme
88+import androidx.compose.material3.OutlinedTextField
89+import androidx.compose.material3.Scaffold
90+import androidx.compose.material3.SnackbarDuration
91+import androidx.compose.material3.SwipeToDismissBox
92+import androidx.compose.material3.SwipeToDismissBoxValue
93+import androidx.compose.material3.rememberSwipeToDismissBoxState
94+import androidx.compose.material3.SnackbarHost
95+import androidx.compose.material3.SnackbarHostState
96+import androidx.compose.material3.SnackbarResult
97+import androidx.compose.material3.Surface
98+import androidx.compose.material3.Text
99+import androidx.compose.material3.TextButton
100+import androidx.compose.material3.TopAppBar
101+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
102+import androidx.compose.runtime.Composable
103+import androidx.compose.runtime.CompositionLocalProvider
104+import androidx.compose.runtime.DisposableEffect
105+import androidx.compose.runtime.LaunchedEffect
106+import androidx.compose.runtime.derivedStateOf
107+import androidx.compose.runtime.getValue
108+import androidx.compose.runtime.mutableIntStateOf
109+import androidx.compose.runtime.mutableLongStateOf
110+import androidx.compose.runtime.mutableStateOf
111+import androidx.compose.runtime.produceState
112+import androidx.compose.runtime.remember
113+import androidx.compose.runtime.rememberCoroutineScope
114+import androidx.compose.runtime.rememberUpdatedState
115+import androidx.compose.runtime.saveable.rememberSaveable
116+import androidx.compose.runtime.setValue
117+import androidx.compose.runtime.snapshotFlow
118+import androidx.compose.foundation.text.KeyboardActions
119+import androidx.compose.foundation.text.KeyboardOptions
120+import androidx.compose.ui.text.input.KeyboardCapitalization
121+import androidx.compose.ui.Alignment
122+import androidx.compose.ui.Modifier
123+import androidx.compose.ui.draw.alpha
124+import androidx.compose.ui.draw.clip
125+import androidx.compose.ui.focus.focusRequester
126+import androidx.compose.ui.input.key.Key
127+import androidx.compose.ui.input.key.KeyEventType
128+import androidx.compose.ui.input.key.isCtrlPressed
129+import androidx.compose.ui.input.key.isShiftPressed
130+import androidx.compose.ui.input.key.key
131+import androidx.compose.ui.input.key.onPreviewKeyEvent
132+import androidx.compose.ui.input.key.type
133+import androidx.compose.ui.layout.onSizeChanged
134+import androidx.compose.ui.platform.LocalContext
135+import androidx.compose.ui.platform.LocalDensity
136+import androidx.compose.ui.platform.LocalHapticFeedback
137+import androidx.compose.ui.platform.LocalLayoutDirection
138+import androidx.compose.ui.platform.LocalView
139+import androidx.compose.ui.platform.testTag
140+import androidx.compose.ui.unit.LayoutDirection
141+import androidx.compose.ui.res.pluralStringResource
142+import androidx.compose.ui.res.stringResource
143+import androidx.compose.ui.semantics.Role
144+import androidx.compose.ui.semantics.contentDescription
145+import androidx.compose.ui.semantics.heading
146+import androidx.compose.ui.semantics.liveRegion
147+import androidx.compose.ui.semantics.LiveRegionMode
148+import androidx.compose.ui.semantics.invisibleToUser
149+import androidx.compose.ui.semantics.semantics
150+import androidx.compose.ui.text.input.ImeAction
151+import androidx.compose.ui.text.style.TextOverflow
152+import androidx.compose.ui.unit.dp
153+import androidx.lifecycle.compose.collectAsStateWithLifecycle
154+import androidx.lifecycle.viewmodel.compose.viewModel
155+import kotlinx.coroutines.Dispatchers
156+import kotlinx.coroutines.coroutineScope
157+import kotlinx.coroutines.delay
158+import kotlinx.coroutines.flow.collectLatest
159+import kotlinx.coroutines.flow.distinctUntilChanged
160+import kotlinx.coroutines.flow.first
161+import kotlinx.coroutines.launch
162+import kotlinx.coroutines.withContext
163+import soy.iko.opencode.data.model.Part
164+import soy.iko.opencode.data.model.MessageWithParts
165+import soy.iko.opencode.data.model.AssistantMessage
166+import soy.iko.opencode.data.model.Command
167+import soy.iko.opencode.data.model.ReasoningPart
168+import soy.iko.opencode.data.model.TextPart
169+import soy.iko.opencode.data.model.ToolCompleted
170+import soy.iko.opencode.data.model.ToolError
171+import soy.iko.opencode.data.model.ToolPart
172+import soy.iko.opencode.data.model.ToolRunning
173+import soy.iko.opencode.data.model.FilePart
174+import soy.iko.opencode.data.model.sourcePath
175+import soy.iko.opencode.data.model.inputElement
176+import soy.iko.opencode.data.network.EventStreamClient
177+import soy.iko.opencode.data.network.NetworkConfig
178+import soy.iko.opencode.di.AppContainer
179+import soy.iko.opencode.R
180+import soy.iko.opencode.ui.components.CommandPalette
181+import soy.iko.opencode.ui.components.ConnectionBanner
182+import soy.iko.opencode.ui.components.DiffView
183+import soy.iko.opencode.ui.components.PaletteAction
184+import soy.iko.opencode.ui.components.LocalReducedMotion
185+import soy.iko.opencode.ui.components.LocalSearchHighlight
186+import soy.iko.opencode.ui.components.reducedMotionAnimateItem
187+import soy.iko.opencode.ui.components.copyToClipboard
188+import soy.iko.opencode.ui.components.LocalRelativeTimeTick
189+import soy.iko.opencode.ui.components.rememberRelativeTimeTick
190+import soy.iko.opencode.ui.components.rememberVisibilityTransitions
191+import soy.iko.opencode.ui.components.showToast
192+import soy.iko.opencode.ui.components.toImageContext
193+import soy.iko.opencode.ui.vmFactory
194+import soy.iko.opencode.util.runCatchingCancellable
195+
196+// Optional message id to scroll to and briefly highlight on first load (from global search).
197+// Consumed once: after the message is scrolled into view and highlighted, the focus is
198+// cleared so subsequent recompositions don't re-trigger the scroll.
199+@OptIn(ExperimentalMaterial3Api::class)
200+@Composable
201+fun ChatScreen(
202+ container: AppContainer,
203+ sessionId: String,
204+ onBack: () -> Unit,
205+ onOpenFile: ((String) -> Unit)? = null,
206+ onOpenSession: ((String) -> Unit)? = null,
207+ focusMessageId: String? = null,
208+ onEditProfile: ((String) -> Unit)? = null,
209+) {
210+ val vm: ChatViewModel = viewModel(key = sessionId, factory = vmFactory { ChatViewModel(container, sessionId) })
211+ val hasMessages by vm.hasMessages.collectAsStateWithLifecycle()
212+ val running by vm.running.collectAsStateWithLifecycle()
213+ val streamInterrupted by vm.streamInterrupted.collectAsStateWithLifecycle()
214+ val aborting by vm.aborting.collectAsStateWithLifecycle()
215+ val loading by vm.loading.collectAsStateWithLifecycle()
216+ val loadError by vm.loadError.collectAsStateWithLifecycle()
217+ val loadErrorInline by vm.loadErrorInline.collectAsStateWithLifecycle()
218+ val refreshing by vm.refreshing.collectAsStateWithLifecycle()
219+ val models by vm.models.collectAsStateWithLifecycle()
220+ val modelsLoading by vm.modelsLoading.collectAsStateWithLifecycle()
221+ val modelsError by vm.modelsError.collectAsStateWithLifecycle()
222+ val selectedModel by vm.selectedModel.collectAsStateWithLifecycle()
223+ val connectionState by vm.connectionState.collectAsStateWithLifecycle()
224+ val reconnectAttempts by vm.reconnectAttempts.collectAsStateWithLifecycle()
225+ val pendingPermission by vm.pendingPermission.collectAsStateWithLifecycle()
226+ val permissionProgress by vm.permissionProgress.collectAsStateWithLifecycle()
227+ val agents by vm.agents.collectAsStateWithLifecycle()
228+ val agentsLoading by vm.agentsLoading.collectAsStateWithLifecycle()
229+ val agentsError by vm.agentsError.collectAsStateWithLifecycle()
230+ val selectedAgent by vm.selectedAgent.collectAsStateWithLifecycle()
231+ val commands by vm.commands.collectAsStateWithLifecycle()
232+ val commandsLoading by vm.commandsLoading.collectAsStateWithLifecycle()
233+ val commandsError by vm.commandsError.collectAsStateWithLifecycle()
234+ val sessionTitle by vm.sessionTitle.collectAsStateWithLifecycle()
235+ val sessionDeleted by vm.sessionDeleted.collectAsStateWithLifecycle()
236+ val draft by vm.draft.collectAsStateWithLifecycle()
237+ val pendingQuote by vm.pendingQuote.collectAsStateWithLifecycle()
238+ val attachments by vm.attachments.collectAsStateWithLifecycle()
239+ val reverted by vm.reverted.collectAsStateWithLifecycle()
240+ val editing by vm.editing.collectAsStateWithLifecycle()
241+ val revertDiff by vm.revertDiff.collectAsStateWithLifecycle()
242+ val shareUrl by vm.shareUrl.collectAsStateWithLifecycle()
243+ val reconnecting by vm.reconnecting.collectAsStateWithLifecycle()
244+ val sendOnEnter by container.settingsStore.sendOnEnter.collectAsStateWithLifecycle(initialValue = true)
245+ val preferredModelId by container.settingsStore.preferredModelId.collectAsStateWithLifecycle(initialValue = "")
246+ val recentModelEntries by container.recentModelsStore.entries.collectAsStateWithLifecycle()
247+ val compactSpacing by container.settingsStore.compactMessageSpacing.collectAsStateWithLifecycle(initialValue = false)
248+ val notifPermission by container.settingsStore.notifPermission.collectAsStateWithLifecycle(initialValue = true)
249+ val isOnline by container.isOnline.collectAsStateWithLifecycle()
250+ val haptics = LocalHapticFeedback.current
251+ val layoutDirection = LocalLayoutDirection.current
252+ val scope = rememberCoroutineScope()
253+ val shareContext = LocalContext.current
254+ val defaultShareSubject = stringResource(R.string.share_subject)
255+ val sessionLabel = stringResource(R.string.session)
256+ val defaultModelLabel = stringResource(R.string.default_model)
257+ val subtitle = remember(selectedModel?.modelLabel, selectedAgent, defaultModelLabel) {
258+ buildString {
259+ append(selectedModel?.modelLabel ?: defaultModelLabel)
260+ selectedAgent?.let { append(" · $it") }
261+ }
262+ }
263+
264+ // Connection profile (with auth) for rendering inline image attachments.
265+ // Collect as state so a server switch recomposes the image context.
266+ val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
267+ val imageContext = remember(activeConnection?.profile) {
268+ activeConnection?.profile?.toImageContext()
269+ }
270+
271+ // Mark this session as read while the user is viewing it; clear on leave so new
272+ // background activity can badge it again.
273+ DisposableEffect(sessionId) {
274+ container.setCurrentSession(sessionId)
275+ onDispose { if (container.currentSession.value == sessionId) container.setCurrentSession(null) }
276+ }
277+
278+ // Navigate away when the session is deleted via SSE so the user isn't left
279+ // on a zombie screen showing a conversation that no longer exists. Surface a
280+ // toast (not a snackbar) because the screen is about to be popped — a snackbar
281+ // would be torn down before the user could read it, while a toast survives.
282+ // Covers both SSE-driven deletion (deleted on another device) and a user-
283+ // initiated delete from the chat overflow menu (deleteSession in ChatViewModel
284+ // sets the same flag on success).
285+ val deletedContext = LocalContext.current
286+ LaunchedEffect(sessionDeleted) {
287+ if (sessionDeleted) {
288+ showToast(deletedContext, deletedContext.getString(R.string.session_deleted_chat))
289+ onBack()
290+ }
291+ }
292+
293+ val snackbar = remember { SnackbarHostState() }
294+ val retryLabel = stringResource(R.string.retry)
295+ val inputFocusRequester = remember { androidx.compose.ui.focus.FocusRequester() }
296+ // Dedicated focus requester for the in-conversation search field. The overflow menu's
297+ // "Find in conversation" and Ctrl+F previously called inputFocusRequester.requestFocus()
298+ // (the composer), so typing went to the composer instead of the search field.
299+ val searchFocusRequester = remember { androidx.compose.ui.focus.FocusRequester() }
300+ var showModelPicker by rememberSaveable { mutableStateOf(false) }
301+ var showAgentPicker by rememberSaveable { mutableStateOf(false) }
302+ var showTitleMenu by rememberSaveable { mutableStateOf(false) }
303+ var showCommandPicker by rememberSaveable { mutableStateOf(false) }
304+ var showExitConfirm by rememberSaveable { mutableStateOf(false) }
305+ var showOverflowMenu by rememberSaveable { mutableStateOf(false) }
306+ var showRenameDialog by rememberSaveable { mutableStateOf(false) }
307+ var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
308+ var showShellDialog by rememberSaveable { mutableStateOf(false) }
309+ var showPalette by rememberSaveable { mutableStateOf(false) }
310+ var showShortcutsDialog by rememberSaveable { mutableStateOf(false) }
311+ var searchActive by rememberSaveable { mutableStateOf(false) }
312+ var chatSearch by rememberSaveable { mutableStateOf("") }
313+ // Index of the currently-focused in-conversation search match (into the filtered list).
314+ // Drives the up/down navigation: the search bar's arrows step through matches and scroll
315+ // the list to each one, mirroring desktop chat apps (the bare filtered list previously
316+ // required reading each message to find the term).
317+ var searchPos by rememberSaveable { mutableIntStateOf(0) }
318+ // Summarize and init (generate AGENTS.md) are irreversible, so a single tap is gated behind
319+ // a confirmation dialog (from both the overflow menu and the command palette).
320+ var showSummarizeConfirm by rememberSaveable { mutableStateOf(false) }
321+ var showInitConfirm by rememberSaveable { mutableStateOf(false) }
322+
323+ // Command-palette entries (keyboard-first: Ctrl+K). Labels resolved here so the remembered
324+ // action list doesn't need a Composable context. Actions map to the same handlers the
325+ // overflow menu drives, so the palette is a keyboard shortcut to them, not a parallel path.
326+ val paletteModelLabel = stringResource(R.string.palette_switch_model)
327+ val paletteAgentLabel = stringResource(R.string.palette_choose_agent)
328+ val paletteCommandLabel = stringResource(R.string.palette_run_command)
329+ val paletteSummarizeLabel = stringResource(R.string.palette_summarize)
330+ val paletteRenameLabel = stringResource(R.string.rename_session_chat)
331+ val paletteShellLabel = stringResource(R.string.run_shell_command)
332+ val paletteActions = remember(
333+ paletteModelLabel, paletteAgentLabel, paletteCommandLabel,
334+ paletteSummarizeLabel, paletteRenameLabel, paletteShellLabel, hasMessages, running,
335+ ) {
336+ buildList {
337+ add(PaletteAction("model", paletteModelLabel) { showModelPicker = true })
338+ add(PaletteAction("agent", paletteAgentLabel) { showAgentPicker = true })
339+ add(PaletteAction("command", paletteCommandLabel) { showCommandPicker = true })
340+ if (hasMessages && !running) add(PaletteAction("summarize", paletteSummarizeLabel) { showSummarizeConfirm = true })
341+ if (!running) add(PaletteAction("shell", paletteShellLabel) { showShellDialog = true })
342+ add(PaletteAction("rename", paletteRenameLabel) { showRenameDialog = true })
343+ }
344+ }
345+
346+ // Keep the screen awake while the agent is working in *this* session. The foreground
347+ // service that holds process priority during a run is managed app-wide off
348+ // container.anyRunActive (see OpencodeApp) so it survives navigating away from the chat
349+ // mid-run — otherwise leaving the screen would drop priority and Doze could choke the
350+ // SSE stream before the completion notification fires.
351+ val currentView = LocalView.current
352+ val appContext = LocalContext.current.applicationContext
353+ DisposableEffect(running) {
354+ currentView.keepScreenOn = running
355+ onDispose { currentView.keepScreenOn = false }
356+ }
357+
358+ LaunchedEffect(Unit) {
359+ vm.errorEvents.collect { event ->
360+ // Only a failed *send* is retryable; attaching Retry to any other error (e.g. a
361+ // message-load failure) would silently re-submit the last prompt on tap.
362+ val result = if (event.retryable) {
363+ snackbar.showSnackbar(message = event.message, actionLabel = retryLabel)
364+ } else {
365+ snackbar.showSnackbar(event.message)
366+ }
367+ if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) vm.retryFailed()
368+ }
369+ }
370+
371+ // Stop-undo: abort() emits the last-sent prompt here so this collector can show a
372+ // "Stopped — Undo" snackbar. Tapping Undo re-sends the prompt, recovering an accidental
373+ // Stop without a confirmation dialog (Stop now happens on first tap).
374+ val undoStopLabel = stringResource(R.string.undo)
375+ val stoppedLabel = stringResource(R.string.run_stopped_undo)
376+ LaunchedEffect(Unit) {
377+ vm.stopUndoEvents.collect { prompt ->
378+ if (prompt != null) {
379+ val result = snackbar.showSnackbar(message = stoppedLabel, actionLabel = undoStopLabel)
380+ if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) vm.resendLastPrompt()
381+ }
382+ }
383+ }
384+
385+ // In-place Undo for a user-initiated session delete. The delete is deferred for an undo
386+ // window on the container; show the snackbar over the conversation the user is viewing so
387+ // they can cancel without being navigated to the session list first. If the window expires
388+ // the scheduled delete sets `sessionDeleted` and the effect below navigates away.
389+ //
390+ // collectLatest (not collect): a serialized collect queues each snackbar behind the
391+ // previous one's full window, so under a rapid re-delete (delete A, then delete the same
392+ // session again, or a delete timed just after the first) the VM delete timer (started at
393+ // emit time) fires before the second snackbar is ever shown — a dead Undo button.
394+ // collectLatest cancels the current snackbar and shows the newest immediately, keeping its
395+ // Undo window aligned with its timer. Tracking `pending` mirrors SessionListScreen: when a
396+ // newer delete supersedes the prior snackbar mid-window, the prior session's deferred REST
397+ // delete (still scheduled on the container's scope) would fire with no reachable Undo.
398+ // Withdrawing it via cancelSessionDelete cancels the timer (no-op if it already fired), so
399+ // no session is silently deleted without a chance to undo.
400+ val undoLabel = stringResource(R.string.undo)
401+ val sessionDeletedLabel = stringResource(R.string.session_deleted_chat)
402+ LaunchedEffect(Unit) {
403+ var pending: String? = null
404+ vm.deleteUndoEvents.collectLatest { id ->
405+ pending?.let { vm.cancelSessionDelete(it) }
406+ pending = id
407+ coroutineScope {
408+ val dismisser = launch {
409+ delay(NetworkConfig.undoDeleteDelayMs)
410+ snackbar.currentSnackbarData?.dismiss()
411+ }
412+ val result = snackbar.showSnackbar(
413+ message = sessionDeletedLabel,
414+ actionLabel = undoLabel,
415+ duration = SnackbarDuration.Indefinite,
416+ )
417+ dismisser.cancel()
418+ if (result == SnackbarResult.ActionPerformed) {
419+ vm.cancelSessionDelete(id)
420+ pending = null
421+ }
422+ }
423+ }
424+ }
425+
426+ // --- Attachments, voice, and share-link plumbing ---
427+ val attachFailedMsg = stringResource(R.string.attachment_failed)
428+ val noCameraMsg = stringResource(R.string.no_camera_app)
429+ val noVoiceMsg = stringResource(R.string.no_voice_app)
430+ val voicePrompt = stringResource(R.string.voice_prompt)
431+ val linkCopiedMsg = stringResource(R.string.link_copied)
432+ val ttsUnavailableMsg = stringResource(R.string.tts_unavailable)
433+
434+ // Count of picks currently being read + base64-encoded off the main thread, so the
435+ // composer can show a staging placeholder immediately (chips only materialize once done).
436+ // Backed by the ViewModel (vm.stagingCount / vm.stagingFileTotal) so the staging state
437+ // survives a configuration change: the encode work runs on viewModelScope (which
438+ // survives rotation), so the counters must live on the VM too — remember { mutableStateOf
439+ // } in the composable would reset to 0 on rotation and lose the staging chip mid-encode.
440+ val stagingCount by vm.stagingCount.collectAsStateWithLifecycle()
441+ val stagingFileTotal by vm.stagingFileTotal.collectAsStateWithLifecycle()
442+
443+ // Convert each picked Uri to a base64 attachment on the VM's scope (survives rotation),
444+ // honoring the per-prompt count cap and surfacing per-file errors via the VM's errorEvents.
445+ fun stageUris(uris: List<Uri>) {
446+ vm.stageUris(uris, appContext)
447+ }
448+
449+ val photoPicker = rememberLauncherForActivityResult(
450+ ActivityResultContracts.PickMultipleVisualMedia(NetworkConfig.maxAttachments),
451+ ) { uris -> stageUris(uris) }
452+
453+ val filePicker = rememberLauncherForActivityResult(
454+ ActivityResultContracts.GetMultipleContents(),
455+ ) { uris -> stageUris(uris) }
456+
457+ // Uri survives a config change mid-capture so the TakePicture result still resolves.
458+ var pendingCaptureUri by rememberSaveable { mutableStateOf<Uri?>(null) }
459+ val cameraLauncher = rememberLauncherForActivityResult(
460+ ActivityResultContracts.TakePicture(),
461+ ) { success ->
462+ val uri = pendingCaptureUri
463+ pendingCaptureUri = null
464+ if (success && uri != null) stageUris(listOf(uri))
465+ }
466+
467+ val voiceLauncher = rememberLauncherForActivityResult(
468+ ActivityResultContracts.StartActivityForResult(),
469+ ) { result ->
470+ if (result.resultCode == Activity.RESULT_OK) {
471+ val spoken = result.data
472+ ?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
473+ ?.firstOrNull()
474+ ?.takeIf { it.isNotBlank() }
475+ if (spoken != null) {
476+ // Append to the existing draft rather than replacing, so dictation adds to
477+ // whatever the user already typed.
478+ val current = vm.draft.value
479+ val combined = if (current.isBlank()) spoken else "$current $spoken"
480+ vm.updateDraft(combined.take(NetworkConfig.maxDraftLengthChars))
481+ }
482+ }
483+ }
484+
485+ fun launchCamera() {
486+ val uri = newCameraCaptureUri(appContext)
487+ if (uri == null) {
488+ scope.launch { snackbar.showSnackbar(attachFailedMsg) }
489+ return
490+ }
491+ pendingCaptureUri = uri
492+ runCatching { cameraLauncher.launch(uri) }
493+ .onFailure { pendingCaptureUri = null; scope.launch { snackbar.showSnackbar(noCameraMsg) } }
494+ }
495+
496+ fun launchVoice() {
497+ val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
498+ putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
499+ putExtra(RecognizerIntent.EXTRA_PROMPT, voicePrompt)
500+ }
501+ runCatching { voiceLauncher.launch(intent) }
502+ .onFailure { scope.launch { snackbar.showSnackbar(noVoiceMsg) } }
503+ }
504+
505+ // A freshly-created share link is copied to the clipboard so the user can paste it right away.
506+ LaunchedEffect(Unit) {
507+ vm.shareLinkEvents.collect { url ->
508+ copyToClipboard(shareContext, shareContext.getString(R.string.clip_label_share), url)
509+ snackbar.showSnackbar(linkCopiedMsg)
510+ }
511+ }
512+
513+ // Navigate to a session freshly branched from a message (branchFrom), once it's created.
514+ // Keyed on Unit (like the sibling one-shot collectors above): keying on the onOpenSession
515+ // lambda would cancel/restart this collector whenever the lambda's identity changes, risking
516+ // a dropped branch-navigation event in the gap. The lambda is wrapped in rememberUpdatedState
517+ // so the Unit-keyed collector invokes the LATEST lambda instead of the one captured at first
518+ // composition — a latent footgun if a future change makes onOpenSession depend on current
519+ // Compose state (e.g. pane-aware routing). The collector itself is never cancelled, so no
520+ // event is dropped.
521+ val branchedMsg = stringResource(R.string.branch_created)
522+ val currentOnOpenSession by rememberUpdatedState(onOpenSession)
523+ LaunchedEffect(Unit) {
524+ vm.branchEvents.collect { newId ->
525+ showToast(shareContext, branchedMsg)
526+ currentOnOpenSession?.invoke(newId)
527+ }
528+ }
529+
530+ // Stage images shared into the app (via the system share sheet) as attachments.
531+ // Keyed on pendingSharedMedia (not Unit) so the effect re-fires when new media arrives
532+ // while this ChatScreen is already composed — which happens in two-pane mode, where the
533+ // detail ChatScreen is persistent (keyed on sessionId) and a LaunchedEffect(Unit) would
534+ // only fire on first composition. consumePendingSharedMedia() atomically drains the
535+ // pending list (compareAndSet to empty), so re-firing on the now-empty value is a no-op.
536+ val pendingSharedMedia by container.pendingSharedMedia.collectAsStateWithLifecycle()
537+ LaunchedEffect(pendingSharedMedia) {
538+ val shared = container.consumePendingSharedMedia()
539+ if (shared.isNotEmpty()) stageUris(shared.map { Uri.parse(it) })
540+ }
541+
542+ fun doSend() {
543+ if (vm.send(draft)) {
544+ // TextHandleMove (a light tick) is semantically right for a send commit;
545+ // LongPress is reserved for long-press/swipe gestures.
546+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
547+ }
548+ }
549+
550+ // The in-conversation search bar is an AnimatedVisibility overlay (not a Dialog/Dropdown),
551+ // so it has no built-in back handling. Close it first on back press, before the exit
552+ // confirmation or navigation-away can fire — standard Android "back closes the topmost
553+ // overlay" behavior. Declared before the exit-confirm BackHandler so it takes precedence
554+ // when both could be active (search open + a run in progress).
555+ BackHandler(enabled = searchActive) {
556+ searchActive = false
557+ chatSearch = ""
558+ }
559+
560+ BackHandler(enabled = running && !showModelPicker && !showAgentPicker && !showCommandPicker && !searchActive) { showExitConfirm = true }
561+
562+ Scaffold(
563+ // Hardware-keyboard shortcuts (tablets / DeX / Chromebooks): Ctrl+K opens the command
564+ // palette; Escape closes it, or stops a run. onPreviewKeyEvent on the Scaffold root sees
565+ // events before the focused text field, but only Ctrl+K / Escape are consumed so normal
566+ // typing (and the composer's own Enter-to-send handling) is untouched.
567+ modifier = Modifier.onPreviewKeyEvent { ev ->
568+ if (ev.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
569+ when {
570+ ev.isCtrlPressed && ev.key == Key.K -> { showPalette = true; true }
571+ // Ctrl+F opens in-conversation search (the standard find shortcut), focusing
572+ // the field so the user can type immediately.
573+ ev.isCtrlPressed && ev.key == Key.F -> { searchActive = true; true }
574+ ev.key == Key.Escape && showPalette -> { showPalette = false; true }
575+ ev.key == Key.Escape && searchActive -> { searchActive = false; chatSearch = ""; true }
576+ ev.key == Key.Escape && running && !showExitConfirm -> {
577+ vm.abort(); true
578+ }
579+ else -> false
580+ }
581+ },
582+ topBar = {
583+ TopAppBar(
584+ title = {
585+ val changeLabel = stringResource(R.string.change_model_agent)
586+ // The subtitle reads "model · agent", so tapping the title offers *both*
587+ // — a small menu picks which picker to open. Previously the tap went
588+ // straight to the model picker, leaving the agent changeable only via
589+ // the overflow menu despite the title implying otherwise.
590+ Box {
591+ Row(
592+ // mergeDescendants so TalkBack reads the title/subtitle Texts as one
593+ // node; the click label rides on `clickable` (onClickLabel) instead of
594+ // an explicit contentDescription, which would have SUPPRESSED the child
595+ // Texts and announced only "Change model and agent".
596+ modifier = Modifier
597+ .clickable(
598+ role = Role.Button,
599+ onClickLabel = changeLabel,
600+ ) { showTitleMenu = true }
601+ .semantics(mergeDescendants = true) {},
602+ verticalAlignment = Alignment.CenterVertically,
603+ ) {
604+ Column(modifier = Modifier.weight(1f, fill = false)) {
605+ // maxLines=1 + Ellipsis so a long session title can't wrap and
606+ // push the top bar to multiple lines, shifting the whole layout.
607+ Text(
608+ sessionTitle ?: sessionLabel,
609+ maxLines = 1,
610+ overflow = TextOverflow.Ellipsis,
611+ )
612+ Text(
613+ subtitle,
614+ style = MaterialTheme.typography.labelSmall,
615+ color = MaterialTheme.colorScheme.onSurfaceVariant,
616+ maxLines = 1,
617+ overflow = TextOverflow.Ellipsis,
618+ )
619+ }
620+ // Always show the affordance so the title reads as tappable even when
621+ // the model catalog failed to load (models empty). Without it the title
622+ // is indistinguishable from plain text and the pickers are effectively
623+ // undiscoverable — and the dropdown items already disable themselves
624+ // while a catalog is loading or unavailable.
625+ Icon(
626+ Icons.Filled.KeyboardArrowDown,
627+ contentDescription = null,
628+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
629+ modifier = Modifier.size(20.dp),
630+ )
631+ }
632+ DropdownMenu(expanded = showTitleMenu, onDismissRequest = { showTitleMenu = false }) {
633+ DropdownMenuItem(
634+ text = { Text(stringResource(R.string.palette_switch_model)) },
635+ enabled = !modelsLoading || models.isNotEmpty(),
636+ onClick = { showTitleMenu = false; showModelPicker = true },
637+ )
638+ DropdownMenuItem(
639+ text = { Text(stringResource(R.string.palette_choose_agent)) },
640+ enabled = !agentsLoading || agents.isNotEmpty(),
641+ onClick = { showTitleMenu = false; showAgentPicker = true },
642+ )
643+ }
644+ }
645+ },
646+ navigationIcon = {
647+ IconButton(onClick = {
648+ if (running) showExitConfirm = true else onBack()
649+ }) {
650+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
651+ }
652+ },
653+ actions = {
654+ // At-a-glance run status in the top bar. When the user is scrolled
655+ // up (jump-to-latest FAB visible), the input-bar Stop button and the
656+ // trailing "working…" row are off-screen, so a top-bar spinner is the
657+ // only signal the agent is still working.
658+ if (running) {
659+ val workingLabel = stringResource(R.string.working)
660+ CircularProgressIndicator(
661+ Modifier.size(18.dp).semantics { contentDescription = workingLabel },
662+ strokeWidth = 2.dp,
663+ )
664+ }
665+ // Manual refresh: forces an SSE reconnect, which re-seeds messages
666+ // from REST. A recovery path when the stream silently drops and the
667+ // auto-reconnect re-seed is slow or fails. Shows a brief spinner as
668+ // immediate tap feedback — the SSE reconnect may not visibly change
669+ // the connection state when already Connected, so without it the tap
670+ // appears to do nothing. Disabled while a run is active: a mid-run
671+ // refresh forces a reconnect that can interrupt the streaming reply,
672+ // and the working spinner already signals "wait" — the recovery path
673+ // is only needed when nothing is actively streaming.
674+ val refreshLabel = stringResource(R.string.refresh)
675+ IconButton(
676+ onClick = { vm.refreshMessages() },
677+ enabled = activeConnection != null && !refreshing && !running,
678+ ) {
679+ if (refreshing) {
680+ CircularProgressIndicator(
681+ Modifier.size(18.dp).semantics { contentDescription = refreshLabel },
682+ strokeWidth = 2.dp,
683+ )
684+ } else {
685+ Icon(Icons.Filled.Refresh, contentDescription = refreshLabel)
686+ }
687+ }
688+ // Overflow menu consolidates the less-frequent actions (share,
689+ // commands, agent, model, rename, delete) so the top bar stays
690+ // scannable on narrow phones — previously 5 icons crowded the bar
691+ // alongside the back button and tappable title. Catalog-loading
692+ // spinners stay inline (next to the overflow) so a load state is
693+ // visible without opening the menu.
694+ val moreLabel = stringResource(R.string.more)
695+ val loadingLabel = stringResource(R.string.loading)
696+ val shareLabel = stringResource(R.string.share_conversation)
697+ val copyAsMarkdownLabel = stringResource(R.string.copy_as_markdown)
698+ val shareAsJsonLabel = stringResource(R.string.share_as_json)
699+ val copyAsJsonLabel = stringResource(R.string.copy_as_json)
700+ val commandsLabel = stringResource(R.string.commands)
701+ val renameLabel = stringResource(R.string.rename_session_chat)
702+ val deleteLabel = stringResource(R.string.delete_session_chat)
703+ val createShareLabel = stringResource(R.string.create_share_link)
704+ val copyShareLabel = stringResource(R.string.copy_share_link)
705+ val stopShareLabel = stringResource(R.string.stop_sharing)
706+ val summarizeLabel = stringResource(R.string.summarize_conversation)
707+ val initLabel = stringResource(R.string.generate_agents_md)
708+ val shellLabel = stringResource(R.string.run_shell_command)
709+ Box {
710+ IconButton(onClick = { showOverflowMenu = true }) {
711+ Icon(Icons.Filled.MoreVert, contentDescription = moreLabel)
712+ }
713+ DropdownMenu(
714+ expanded = showOverflowMenu,
715+ onDismissRequest = { showOverflowMenu = false },
716+ ) {
717+ DropdownMenuItem(
718+ text = { Text(shareLabel) },
719+ enabled = hasMessages,
720+ onClick = {
721+ showOverflowMenu = false
722+ scope.launch {
723+ val md = withContext(Dispatchers.Default) {
724+ buildConversationMarkdown(vm.messages.value, sessionTitle)
725+ }
726+ val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
727+ type = "text/markdown"
728+ putExtra(android.content.Intent.EXTRA_SUBJECT, sessionTitle ?: defaultShareSubject)
729+ putExtra(android.content.Intent.EXTRA_TEXT, md)
730+ }
731+ runCatchingCancellable { shareContext.startActivity(android.content.Intent.createChooser(send, shareLabel)) }
732+ .onFailure { showToast(shareContext, shareContext.getString(R.string.no_share_app)) }
733+ }
734+ },
735+ )
736+ // Copy the whole conversation as Markdown to the clipboard, so a
737+ // user can paste into another app without the share-sheet round-trip.
738+ DropdownMenuItem(
739+ text = { Text(copyAsMarkdownLabel) },
740+ enabled = hasMessages,
741+ onClick = {
742+ showOverflowMenu = false
743+ scope.launch {
744+ val md = withContext(Dispatchers.Default) {
745+ buildConversationMarkdown(vm.messages.value, sessionTitle)
746+ }
747+ copyToClipboard(shareContext, shareContext.getString(R.string.clip_label_message), md)
748+ }
749+ },
750+ )
751+ // Lossless JSON share: the full List<MessageWithParts> serialized with
752+ // the shared OpencodeJson, so tool outputs, file sources, and reasoning
753+ // survive verbatim (the Markdown export truncates/summarizes them).
754+ DropdownMenuItem(
755+ text = { Text(shareAsJsonLabel) },
756+ enabled = hasMessages,
757+ onClick = {
758+ showOverflowMenu = false
759+ scope.launch {
760+ val json = withContext(Dispatchers.Default) {
761+ buildConversationJson(vm.messages.value)
762+ }
763+ val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
764+ type = "application/json"
765+ putExtra(android.content.Intent.EXTRA_SUBJECT, sessionTitle ?: defaultShareSubject)
766+ putExtra(android.content.Intent.EXTRA_TEXT, json)
767+ }
768+ runCatchingCancellable { shareContext.startActivity(android.content.Intent.createChooser(send, shareAsJsonLabel)) }
769+ .onFailure { showToast(shareContext, shareContext.getString(R.string.no_share_app)) }
770+ }
771+ },
772+ )
773+ DropdownMenuItem(
774+ text = { Text(copyAsJsonLabel) },
775+ enabled = hasMessages,
776+ onClick = {
777+ showOverflowMenu = false
778+ scope.launch {
779+ val json = withContext(Dispatchers.Default) {
780+ buildConversationJson(vm.messages.value)
781+ }
782+ copyToClipboard(shareContext, shareContext.getString(R.string.clip_label_message), json)
783+ }
784+ },
785+ )
786+ DropdownMenuItem(
787+ text = { Text(commandsLabel) },
788+ enabled = !commandsLoading || commands.isNotEmpty(),
789+ onClick = { showOverflowMenu = false; showCommandPicker = true },
790+ )
791+ DropdownMenuItem(
792+ text = { Text(stringResource(R.string.keyboard_shortcuts)) },
793+ onClick = { showOverflowMenu = false; showShortcutsDialog = true },
794+ )
795+ DropdownMenuItem(
796+ text = { Text(stringResource(R.string.find_in_conversation)) },
797+ onClick = {
798+ showOverflowMenu = false
799+ searchActive = true
800+ // Focus is requested by the LaunchedEffect(searchActive)
801+ // below, which waits for the search field to be composed
802+ // before requesting focus (calling requestFocus here would
803+ // target a field that isn't in the composition yet).
804+ },
805+ )
806+ androidx.compose.material3.HorizontalDivider()
807+ // Session sharing: create a public link, copy an existing one, or revoke it.
808+ if (shareUrl == null) {
809+ DropdownMenuItem(
810+ text = { Text(createShareLabel) },
811+ enabled = hasMessages,
812+ onClick = { showOverflowMenu = false; vm.shareSession() },
813+ )
814+ } else {
815+ DropdownMenuItem(
816+ text = { Text(copyShareLabel) },
817+ onClick = {
818+ showOverflowMenu = false
819+ shareUrl?.let {
820+ copyToClipboard(shareContext, shareContext.getString(R.string.clip_label_share), it)
821+ scope.launch { snackbar.showSnackbar(linkCopiedMsg) }
822+ }
823+ },
824+ )
825+ DropdownMenuItem(
826+ text = { Text(stopShareLabel) },
827+ onClick = { showOverflowMenu = false; vm.unshareSession() },
828+ )
829+ }
830+ DropdownMenuItem(
831+ text = { Text(summarizeLabel) },
832+ enabled = hasMessages && !running,
833+ onClick = { showOverflowMenu = false; showSummarizeConfirm = true },
834+ )
835+ DropdownMenuItem(
836+ text = { Text(initLabel) },
837+ enabled = !running,
838+ onClick = { showOverflowMenu = false; showInitConfirm = true },
839+ )
840+ DropdownMenuItem(
841+ text = { Text(shellLabel) },
842+ enabled = !running,
843+ onClick = { showOverflowMenu = false; showShellDialog = true },
844+ )
845+ androidx.compose.material3.HorizontalDivider()
846+ DropdownMenuItem(
847+ text = { Text(renameLabel) },
848+ onClick = { showOverflowMenu = false; showRenameDialog = true },
849+ )
850+ DropdownMenuItem(
851+ text = { Text(deleteLabel, color = MaterialTheme.colorScheme.error) },
852+ onClick = { showOverflowMenu = false; showDeleteDialog = true },
853+ )
854+ }
855+ // While a catalog is initially loading (empty list + loading flag),
856+ // show a tiny inline spinner beside the overflow so the user can see
857+ // a load is in progress without opening the menu. Once loaded the
858+ // spinner disappears; the menu items are always enabled (tapping a
859+ // picker with no items shows the "no models/agents/commands" state).
860+ val catalogLoading = (commandsLoading && commands.isEmpty()) ||
861+ (agentsLoading && agents.isEmpty()) ||
862+ (modelsLoading && models.isEmpty())
863+ if (catalogLoading) {
864+ CircularProgressIndicator(
865+ Modifier
866+ .align(Alignment.TopEnd)
867+ .size(10.dp)
868+ .semantics { contentDescription = loadingLabel },
869+ strokeWidth = 1.dp,
870+ )
871+ }
872+ }
873+ },
874+ )
875+ },
876+ snackbarHost = { SnackbarHost(snackbar) },
877+ bottomBar = {
878+ val queuedFollowUp by vm.queuedFollowUp.collectAsStateWithLifecycle()
879+ val todoPlan by vm.todoPlan.collectAsStateWithLifecycle()
880+ val queuedOffline by vm.outbox.collectAsStateWithLifecycle()
881+ val outboxSending by container.outboxSending.collectAsStateWithLifecycle()
882+ Column {
883+ // Pinned above the composer so the agent's plan and progress stay visible
884+ // while scrolled up mid-run; renders nothing when there's no plan.
885+ TodoPlanBar(todoPlan)
886+ val bannerMotion = rememberVisibilityTransitions()
887+ AnimatedVisibility(
888+ visible = reverted,
889+ enter = bannerMotion.enter,
890+ exit = bannerMotion.exit,
891+ ) {
892+ RevertBanner(
893+ diff = revertDiff,
894+ isEditing = editing,
895+ onUndo = { vm.unrevert() },
896+ )
897+ }
898+ AnimatedVisibility(
899+ visible = queuedOffline.isNotEmpty(),
900+ enter = bannerMotion.enter,
901+ exit = bannerMotion.exit,
902+ ) {
903+ OutboxBanner(
904+ count = queuedOffline.size,
905+ sending = outboxSending,
906+ onFlush = { vm.flushQueued() },
907+ onDiscard = { vm.discardAllQueued() },
908+ )
909+ }
910+ AnimatedVisibility(
911+ visible = pendingQuote != null,
912+ enter = bannerMotion.enter,
913+ exit = bannerMotion.exit,
914+ ) {
915+ pendingQuote?.let { QuoteReplyBanner(it, onCancel = { vm.cancelQuoteReply() }) }
916+ }
917+ ChatInputBar(
918+ value = draft,
919+ onValueChange = vm::updateDraft,
920+ running = running,
921+ aborting = aborting,
922+ enabled = activeConnection != null,
923+ sendOnEnter = sendOnEnter,
924+ onSend = ::doSend,
925+ onAbort = { vm.abort() },
926+ queuedFollowUp = queuedFollowUp,
927+ onQueueFollowUp = vm::queueFollowUp,
928+ onCancelQueue = { vm.queueFollowUp("") },
929+ focusRequester = inputFocusRequester,
930+ attachments = attachments,
931+ staging = stagingCount > 0,
932+ stagingFileCount = stagingFileTotal,
933+ onRemoveAttachment = vm::removeAttachment,
934+ onPickPhoto = {
935+ photoPicker.launch(
936+ PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly),
937+ )
938+ },
939+ onPickFile = { filePicker.launch("*/*") },
940+ onCamera = { launchCamera() },
941+ onVoice = { launchVoice() },
942+ onPasteImage = { stageUris(it) },
943+ commands = commands,
944+ onRunCommand = { cmd ->
945+ vm.runCommand(cmd)
946+ // Clear the draft so the typed /prefix doesn't linger after the
947+ // command fires (the command picker confirmation, if any, is
948+ // handled inside runCommand).
949+ vm.updateDraft("")
950+ },
951+ )
952+ }
953+ },
954+ ) { padding ->
955+ // Collect messages inside the content lambda so streaming token updates
956+ // recompose only this subtree, not the top bar / input bar / 20+ other
957+ // state reads in the ChatScreen body.
958+ val messages by vm.messages.collectAsStateWithLifecycle()
959+ val optimisticStatuses by vm.optimisticStatuses.collectAsStateWithLifecycle()
960+ val listState = rememberLazyListState()
961+ val contentScope = rememberCoroutineScope()
962+ val timeTick = rememberRelativeTimeTick()
963+ // Dismiss the soft keyboard when the user scrolls the message list, a standard Android
964+ // chat affordance for reading while the IME is open. Watching isScrollInProgress via
965+ // snapshotFlow avoids wiring a nested scroll connection and fires on any scroll gesture.
966+ val keyboardController = androidx.compose.ui.platform.LocalSoftwareKeyboardController.current
967+ androidx.compose.runtime.LaunchedEffect(listState) {
968+ androidx.compose.runtime.snapshotFlow { listState.isScrollInProgress }
969+ .distinctUntilChanged()
970+ .collect { scrolling -> if (scrolling) keyboardController?.hide() }
971+ } // Group messages by day and interleave date separators so a long conversation
972+ // has visual "Today"/"Yesterday"/date breaks. Computed once per messages
973+ // emission (memoized) so a scroll-induced recomposition doesn't re-scan. Lives
974+ // outside the PullToRefreshBox so the auto-scroll effects below can reference
975+ // the same list the LazyColumn renders (separators shift the indices vs. the
976+ // raw messages list, so scroll targets must be in listItems space).
977+ val todayLabel = stringResource(R.string.today)
978+ val yesterdayLabel = stringResource(R.string.yesterday)
979+ // In-conversation search: when active, narrow to messages whose text contains the query.
980+ // Computed on the filtered set so day separators still align with the visible messages.
981+ // Debounced + off-main-thread so a keystroke-by-keystroke filter walk over a long
982+ // conversation (with many tool parts whose state stringification is non-trivial)
983+ // doesn't jank the UI. produceState yields the empty list immediately and publishes
984+ // the filtered result once the debounce settles.
985+ val searchQuery = chatSearch.trim()
986+ // When no search is active, skip the produceState indirection entirely and use the
987+ // messages list directly. produceState keyed on (messages, searchQuery) re-launches
988+ // its coroutine on every messages emission (every streamed token) even when the
989+ // query is empty — the body just re-assigns `value = messages`, but the coroutine
990+ // churn (launch + cancel + state write) ~20x/sec is pure overhead. Using `messages`
991+ // directly when there's no search avoids that, and `buildMessageListItems` keys its
992+ // own remember on the resulting list identity either way.
993+ val searchMessages: List<MessageWithParts> = if (searchQuery.isEmpty()) {
994+ messages
995+ } else {
996+ // Capture the messages list via rememberUpdatedState so the latest list is read
997+ // when the debounce completes, WITHOUT re-launching the produceState coroutine on
998+ // every messages emission. Keying produceState on `messages` restarts the debounce
999+ // delay from zero on every streamed token (~20×/sec); if tokens arrive faster than
1000+ // the debounce interval the filter never runs and search shows "no matches" for the
1001+ // entire run. Keying only on `searchQuery` lets the debounce settle once per query
1002+ // while still filtering against the most recent list.
1003+ val currentMessages by rememberUpdatedState(messages)
1004+ val filtered by produceState(
1005+ initialValue = emptyList(),
1006+ searchQuery,
1007+ ) {
1008+ // Debounce: wait for the user to stop typing before running the filter,
1009+ // so each keystroke doesn't kick off a fresh scan.
1010+ kotlinx.coroutines.delay(NetworkConfig.chatSearchDebounceMs)
1011+ // Run the filter off the main thread — the walk stringifies tool state
1012+ // and scans every part of every message, which is non-trivial work for a
1013+ // long conversation.
1014+ value = withContext(Dispatchers.Default) {
1015+ currentMessages.filter { m ->
1016+ m.parts.any { p ->
1017+ when (p) {
1018+ is TextPart -> p.text.contains(searchQuery, ignoreCase = true)
1019+ is ReasoningPart -> p.text.contains(searchQuery, ignoreCase = true)
1020+ is ToolPart -> {
1021+ // Match the tool name plus its running/completed/error text and the
1022+ // stringified input, so searching for an error string or a file the
1023+ // tool touched actually finds the message it appeared in.
1024+ p.tool.contains(searchQuery, ignoreCase = true) ||
1025+ (p.state is ToolRunning && p.state.title?.contains(searchQuery, ignoreCase = true) == true) ||
1026+ (p.state is ToolCompleted && (p.state.title?.contains(searchQuery, ignoreCase = true) == true ||
1027+ p.state.output?.contains(searchQuery, ignoreCase = true) == true)) ||
1028+ (p.state is ToolError && p.state.error?.contains(searchQuery, ignoreCase = true) == true) ||
1029+ p.state.inputElement()?.toString()?.contains(searchQuery, ignoreCase = true) == true
1030+ }
1031+ is FilePart -> p.filename?.contains(searchQuery, ignoreCase = true) == true ||
1032+ p.sourcePath?.contains(searchQuery, ignoreCase = true) == true
1033+ else -> false
1034+ }
1035+ }
1036+ }
1037+ }
1038+ }
1039+ filtered
1040+ }
1041+ // Keep the focused-match index in range as the query (and thus the match set) changes.
1042+ // A new query resets to the first match (keyed on searchQuery alone so any change —
1043+ // even one that still yields enough matches — jumps to index 0 rather than carrying
1044+ // the old index over to a semantically unrelated message). A shrunk set clamps to
1045+ // the last valid index (keyed on size so a content refresh that shrinks the matches
1046+ // without a query change doesn't leave searchPos out of range).
1047+ LaunchedEffect(searchQuery) {
1048+ searchPos = 0
1049+ }
1050+ LaunchedEffect(searchMessages.size) {
1051+ if (searchPos >= searchMessages.size) searchPos = 0
1052+ }
1053+ val listItems = remember(searchMessages, todayLabel, yesterdayLabel) {
1054+ buildMessageListItems(searchMessages, todayLabel, yesterdayLabel)
1055+ }
1056+ // listItems is a plain (non-snapshot) local, rebuilt each recomposition. The
1057+ // derivedStateOf and snapshotFlow lambdas below are created once (keyless
1058+ // remember / LaunchedEffect(Unit)); reading `listItems` directly inside them
1059+ // would capture the first composition's list — which is empty, since `messages`
1060+ // starts empty — freezing it forever. That pinned isPinnedToBottom at true and
1061+ // AutoScrollSignal.size at 0, breaking streaming auto-scroll and the
1062+ // jump-to-latest FAB. rememberUpdatedState hands those lambdas a stable State
1063+ // whose value tracks the latest list.
1064+ val currentListItems by rememberUpdatedState(listItems)
1065+
1066+ // New-content signal for the jump-to-latest FAB: counts how many new items arrived
1067+ // while the user was scrolled away from the bottom, cleared once they return. A count
1068+ // (vs. a bare dot) tells the user how much they missed at a glance.
1069+ // rememberSaveable (not plain remember) so the badge count survives rotation: plain
1070+ // remember resets to 0 on recreation, hiding the "new content" signal even though new
1071+ // messages are still arriving. Int is Bundle-savable.
1072+ var newContentCount by rememberSaveable { mutableIntStateOf(0) }
1073+
1074+ val isPinnedToBottom by remember {
1075+ derivedStateOf {
1076+ val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
1077+ // When a run is active, the LazyColumn has one trailing "__typing"
1078+ // item after the last list item. Account for it so "pinned to bottom"
1079+ // includes the working indicator — otherwise the pin check targets
1080+ // listItems.lastIndex, the typing row sits just below the viewport,
1081+ // and the user never sees the progress indicator even when pinned.
1082+ val items = currentListItems
1083+ val effectiveLast = if (running) items.size else items.lastIndex
1084+ lastVisible >= effectiveLast || items.isEmpty()
1085+ }
1086+ }
1087+
1088+ // Jump to the newest message once, when the conversation first loads. Guarded by a
1089+ // saveable one-shot flag so it doesn't re-fire on recomposition after a config change
1090+ // (rotation) — rememberLazyListState restores the user's scroll offset, and an
1091+ // unconditional scrollToItem here would clobber it, snapping them back to the bottom.
1092+ var didInitialScroll by rememberSaveable { mutableStateOf(false) }
1093+ LaunchedEffect(listItems.isNotEmpty()) {
1094+ if (listItems.isNotEmpty() && !didInitialScroll) {
1095+ // When a run is active the LazyColumn has a trailing "__typing" row at
1096+ // listItems.size; target it (not listItems.lastIndex) so deep-linking into a
1097+ // running session lands truly pinned to bottom and streaming auto-scroll engages.
1098+ listState.scrollToItem(if (running) listItems.size else listItems.lastIndex)
1099+ didInitialScroll = true
1100+ }
1101+ }
1102+
1103+ // When a run starts, the LazyColumn gains a trailing "__typing" row, so
1104+ // effectiveLast jumps from listItems.lastIndex to listItems.size and the
1105+ // isPinnedToBottom check flips to false the instant you send — freezing
1106+ // auto-scroll. If the user was pinned to the last item just before the run
1107+ // began, bring the typing row into view so the pin (and streaming follow) is
1108+ // preserved. Guard on the raw last-visible index (isPinnedToBottom has already
1109+ // recomputed to false by now) so we don't scroll when the user had scrolled up.
1110+ LaunchedEffect(running) {
1111+ if (running && listItems.isNotEmpty()) {
1112+ val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
1113+ if (lastVisible >= listItems.lastIndex) {
1114+ runCatchingCancellable { listState.animateScrollToItem(listItems.size) }
1115+ }
1116+ }
1117+ }
1118+
1119+ // Scroll the focused search match into view whenever the user steps through results with
1120+ // the up/down arrows. Resolves the filtered message to its slot in listItems (which
1121+ // includes date separators) and animates there.
1122+ //
1123+ // Keyed ONLY on searchPos and searchQuery (not listItems): a bare listItems key re-keys
1124+ // on every streamed token (the list is rebuilt per snapshot), which re-fires this effect
1125+ // ~20×/sec during an active stream — re-scrolling to the focused match on every token
1126+ // and making it impossible to browse other matches with the up/down arrows while content
1127+ // is streaming. snapshotFlow (reading currentListItems, a rememberUpdatedState) waits for
1128+ // the target to appear without re-keying the effect on every list change, mirroring the
1129+ // focusedMessageId pattern below.
1130+ LaunchedEffect(searchPos, searchQuery) {
1131+ if (!searchActive || searchQuery.isEmpty()) return@LaunchedEffect
1132+ val target = searchMessages.getOrNull(searchPos) ?: return@LaunchedEffect
1133+ val targetId = target.info.id
1134+ val index = snapshotFlow {
1135+ currentListItems.indexOfFirst {
1136+ it is MessageListItem.Message && it.message.info.id == targetId
1137+ }
1138+ }.first { it >= 0 }
1139+ runCatchingCancellable { listState.animateScrollToItem(index) }
1140+ }
1141+
1142+ // Global-search deep link: scroll to and briefly highlight the matched message. The
1143+ // focus id is threaded through the chat route from GlobalSearchScreen. The highlight
1144+ // clears itself after a short delay so the user can re-read the surrounding context
1145+ // without a persistent marker. Runs once per focusMessageId (cleared on consume).
1146+ //
1147+ // Keyed ONLY on focusedMessageId (not listItems): a bare listItems key re-keys on every
1148+ // streamed token (the list is rebuilt per snapshot), which cancels the 2.5s delay before
1149+ // it can clear the highlight — re-scrolling to the focus on every token and making an
1150+ // actively-streaming chat unreadable. snapshotFlow waits for the message to appear in the
1151+ // list (it may not be present on the first composition while messages load) without
1152+ // re-keying the effect on every list change.
1153+ // rememberSaveable (not plain remember) so the cleared null survives rotation:
1154+ // plain remember keyed on focusMessageId re-seeds the non-null nav arg on every
1155+ // recreation, re-firing this effect and yanking scroll back to the focus after the
1156+ // user already dismissed the highlight. String? is Bundle-savable.
1157+ var focusedMessageId by rememberSaveable(focusMessageId) { mutableStateOf(focusMessageId) }
1158+ val currentListItemsForFocus by rememberUpdatedState(listItems)
1159+ LaunchedEffect(focusedMessageId) {
1160+ val focus = focusedMessageId ?: return@LaunchedEffect
1161+ // Wait for the focused message to appear in the list, then resolve its index. A
1162+ // first-composition empty list (messages still loading) will populate as the seed
1163+ // arrives; snapshotFlow re-evaluates on each list change until the match is found.
1164+ val index = snapshotFlow {
1165+ currentListItemsForFocus.indexOfFirst {
1166+ it is MessageListItem.Message && it.message.info.id == focus
1167+ }
1168+ }.first { it >= 0 }
1169+ runCatchingCancellable { listState.animateScrollToItem(index) }
1170+ kotlinx.coroutines.delay(2500)
1171+ focusedMessageId = null
1172+ }
1173+
1174+ // Scroll to bottom when the IME opens and the user was already pinned to bottom, so the
1175+ // latest reply stays visible as the composer rises. Without this the IME can obscure the
1176+ // last message even though the user was reading it a moment ago. WindowInsets.ime is a
1177+ // @Composable property, so we read it inside a snapshotFlow registered in the composable
1178+ // scope; the flow re-emits whenever the IME inset changes (open/close transitions).
1179+ val density = androidx.compose.ui.platform.LocalDensity.current
1180+ var wasImeOpen by remember { mutableStateOf(false) }
1181+ val imeBottomPx = WindowInsets.ime.getBottom(density)
1182+ androidx.compose.runtime.LaunchedEffect(imeBottomPx, listItems.isNotEmpty(), isPinnedToBottom) {
1183+ if (listItems.isEmpty()) return@LaunchedEffect
1184+ val imeOpen = imeBottomPx > 0
1185+ // Scroll to bottom only on the open transition (not close) and only when the user
1186+ // was already pinned to bottom — otherwise we'd yank them out of a mid-history read.
1187+ val shouldScroll = imeOpen && !wasImeOpen && isPinnedToBottom
1188+ if (shouldScroll) {
1189+ runCatchingCancellable {
1190+ listState.animateScrollToItem(if (running) listItems.size else listItems.lastIndex)
1191+ }
1192+ }
1193+ wasImeOpen = imeOpen
1194+ }
1195+
1196+ LaunchedEffect(Unit) {
1197+ var prevSize = 0
1198+ var prevLen = 0
1199+ snapshotFlow {
1200+ // Track list size + the streaming length of the last part so we auto-scroll
1201+ // as content arrives, plus the pinned flag. A small data class with primitive
1202+ // fields avoids the per-frame boxing that Triple<Int,Int,Boolean> pays
1203+ // (snapshotFlow re-evaluates this lambda every snapshot), and reading only the
1204+ // last part's length is O(1) vs. a sumOf over every part. Covering reasoning
1205+ // and tool output — not just TextPart — keeps a pinned view following a long
1206+ // reasoning/tool block while it streams (previously it stalled at 0).
1207+ val lastLen = streamingContentLength(messages.lastOrNull()?.parts?.lastOrNull())
1208+ AutoScrollSignal(currentListItems.size, lastLen, isPinnedToBottom)
1209+ }.collect { signal ->
1210+ if (signal.pinned) {
1211+ // Back at the bottom: clear the new-content badge.
1212+ newContentCount = 0
1213+ if (signal.size > 0) {
1214+ // Scroll to the effective last index, including the trailing
1215+ // "__typing" row when a run is active so the working indicator
1216+ // is brought into view (not just the last message).
1217+ val items = currentListItems
1218+ val target = if (running) items.size else items.lastIndex
1219+ listState.scrollToItem(target)
1220+ }
1221+ } else if (signal.size > prevSize || signal.lastTextLength > prevLen) {
1222+ // Content arrived while scrolled up — increment the jump-to-latest badge.
1223+ // Only count whole new items (not per-token growth) so the number reflects
1224+ // new messages, not a token count.
1225+ if (signal.size > prevSize) newContentCount += signal.size - prevSize
1226+ else if (newContentCount == 0) newContentCount = 1
1227+ }
1228+ prevSize = signal.size
1229+ prevLen = signal.lastTextLength
1230+ }
1231+ }
1232+
1233+ CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
1234+ // The bottomBar (ChatInputBar) already has imePadding, and the Scaffold's
1235+ // content padding accounts for the bottomBar's raised height, so the message
1236+ // list is already above the keyboard. Adding imePadding here would double-apply
1237+ // the IME inset and push messages too far up.
1238+ //
1239+ // BoxWithConstraints so the FABs and search bar can align to the capped (800dp)
1240+ // message list on wide screens instead of floating at the screen edges — on a
1241+ // tablet the list is a centered column, so edge-aligned FABs are visually detached.
1242+ // The horizontal margin below is applied to the FAB/search containers so they
1243+ // track the list's left/right edges.
1244+ androidx.compose.foundation.layout.BoxWithConstraints(
1245+ modifier = Modifier.fillMaxSize().padding(padding),
1246+ ) {
1247+ val screenMaxWidth = maxWidth
1248+ val listMaxWidth = NetworkConfig.chatContentMaxWidthDp.dp
1249+ // Horizontal margin between the capped list's edge and the screen edge (0 on
1250+ // screens narrower than the cap). Applied as start/end padding so the FABs and
1251+ // search bar sit at the list's edges, not the screen's.
1252+ val sideMargin = ((screenMaxWidth - listMaxWidth) / 2).coerceAtLeast(0.dp)
1253+ if (activeConnection == null) {
1254+ Column(
1255+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
1256+ horizontalAlignment = Alignment.CenterHorizontally,
1257+ ) {
1258+ Text(
1259+ stringResource(R.string.not_connected),
1260+ color = MaterialTheme.colorScheme.error,
1261+ )
1262+ Spacer(Modifier.size(12.dp))
1263+ Button(onClick = { vm.reconnect() }, enabled = !reconnecting) {
1264+ if (reconnecting) {
1265+ CircularProgressIndicator(
1266+ Modifier.size(20.dp),
1267+ color = MaterialTheme.colorScheme.onPrimary,
1268+ strokeWidth = 2.dp,
1269+ )
1270+ } else {
1271+ Text(stringResource(R.string.reconnect))
1272+ }
1273+ }
1274+ }
1275+ } else {
1276+ val bannerVisible = !isOnline || connectionState != EventStreamClient.ConnectionState.Connected
1277+ // Measure the banner's actual height instead of hardcoding 44dp. At large
1278+ // accessibility font scales the banner's labelMedium text + spinner + Retry
1279+ // button exceed 44dp, so a fixed inset lets the first message hide behind it
1280+ // (the same hazard FileViewScreen's overlay calls out). onSizeChanged only
1281+ // fires while the banner is composed (visible), so gate the inset on
1282+ // bannerVisible and fall back to 44dp until the first measurement arrives.
1283+ var bannerHeightPx by remember { mutableIntStateOf(0) }
1284+ // Track the search bar height so the list's top padding can grow when search
1285+ // is active — otherwise a scrolled-to match lands at index 0, hidden behind
1286+ // the floating search bar (the "scroll-under-the-search-bar" bug).
1287+ var searchbarHeightPx by remember { mutableIntStateOf(0) }
1288+ val density = LocalDensity.current
1289+ val topPad = if (bannerVisible) {
1290+ if (bannerHeightPx > 0) {
1291+ with(density) { bannerHeightPx.toDp() } + 4.dp
1292+ } else {
1293+ 44.dp
1294+ }
1295+ } else {
1296+ 16.dp
1297+ } + if (searchActive && searchbarHeightPx > 0) {
1298+ with(density) { searchbarHeightPx.toDp() } + 4.dp
1299+ } else {
1300+ 0.dp
1301+ }
1302+ ConnectionBanner(
1303+ state = connectionState,
1304+ modifier = Modifier.align(Alignment.TopCenter).onSizeChanged { bannerHeightPx = it.height },
1305+ isOnline = isOnline,
1306+ // On a hard endpoint failure, retry by forcing an SSE reconnect (which
1307+ // re-seeds from REST). refreshMessages is the right recovery path
1308+ // when the connection is present but the stream died; reconnect()
1309+ // is the path when the whole connection is gone (handled by the
1310+ // separate "Not connected" state below).
1311+ onRetry = { vm.refreshMessages() },
1312+ // On an auth failure (401/403), retrying with the same bad credentials
1313+ // is futile — offer a one-tap path to edit the active profile's
1314+ // credentials instead of making the user hunt for the server list.
1315+ onEditCredentials = {
1316+ val profileId = activeConnection?.profile?.id
1317+ if (profileId != null) onEditProfile?.invoke(profileId)
1318+ },
1319+ reconnectAttempts = reconnectAttempts,
1320+ )
1321+ // Persistent inline error banner for a mid-conversation load failure.
1322+ // Unlike the one-shot snackbar (which fires once per streak then goes
1323+ // silent), this stays visible until the stream recovers, so a persistent
1324+ // outage doesn't read as "everything is fine" after the snackbar dismisses.
1325+ // Anchored at the top, below the connection banner, so it doesn't overlap
1326+ // the message list.
1327+ if (loadErrorInline && hasMessages) {
1328+ Surface(
1329+ color = MaterialTheme.colorScheme.errorContainer,
1330+ shape = MaterialTheme.shapes.small,
1331+ modifier = Modifier
1332+ .align(Alignment.TopCenter)
1333+ .padding(top = if (bannerVisible) topPad else 8.dp)
1334+ .padding(horizontal = 16.dp)
1335+ .semantics { liveRegion = LiveRegionMode.Polite },
1336+ ) {
1337+ Row(
1338+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
1339+ verticalAlignment = Alignment.CenterVertically,
1340+ horizontalArrangement = Arrangement.spacedBy(8.dp),
1341+ ) {
1342+ Icon(
1343+ Icons.Filled.ErrorOutline,
1344+ contentDescription = null,
1345+ modifier = Modifier.size(16.dp),
1346+ tint = MaterialTheme.colorScheme.onErrorContainer,
1347+ )
1348+ Text(
1349+ stringResource(R.string.load_error_persistent),
1350+ style = MaterialTheme.typography.labelMedium,
1351+ color = MaterialTheme.colorScheme.onErrorContainer,
1352+ modifier = Modifier.weight(1f),
1353+ )
1354+ TextButton(
1355+ onClick = { vm.refreshMessages() },
1356+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp),
1357+ ) {
1358+ Text(stringResource(R.string.retry))
1359+ }
1360+ }
1361+ }
1362+ }
1363+ // Pull-to-refresh wraps ALL content states (loading, error, empty, list) so
1364+ // recovery is a swipe away everywhere — not just when the message list is
1365+ // populated. Previously PTR was only around the populated list, leaving the
1366+ // error/loading states reachable only via a button tap.
1367+ PullToRefreshBox(
1368+ isRefreshing = refreshing,
1369+ // Suppress the refresh action while a run is active: a mid-stream
1370+ // refresh forces an SSE reconnect that can interrupt the actively
1371+ // streaming reply (matching the top-bar refresh button, which is
1372+ // also disabled while running). The pull gesture still responds, but
1373+ // the release no-ops instead of triggering a disruptive re-seed.
1374+ onRefresh = { if (!running) vm.refreshMessages() },
1375+ // Cap width and center on large screens for readability — full-width
1376+ // bubbles stretch edge-to-edge on tablets, hurting line-length comfort.
1377+ modifier = Modifier.fillMaxSize().wrapContentWidth(Alignment.CenterHorizontally)
1378+ .widthIn(max = NetworkConfig.chatContentMaxWidthDp.dp),
1379+ ) {
1380+ // Reserve top space when the banner is visible so it doesn't overlap the
1381+ // first message / state content. (topPad computed above.)
1382+ if (loading && messages.isEmpty()) {
1383+ // Skeleton bubbles pre-structure the layout so the perceived load is faster
1384+ // than a bare centered spinner and the first real messages pop into an
1385+ // already-shaped list instead of a blank-then-pop.
1386+ ChatSkeleton(topPad = topPad)
1387+ } else if (loadError && messages.isEmpty()) {
1388+ // A failed load with nothing to show. Distinct from the empty
1389+ // conversation state below — the conversation isn't empty, it
1390+ // failed to load, and offering "Start a conversation" here is
1391+ // misleading. Offer a Retry instead, mirroring SessionListScreen.
1392+ Column(
1393+ modifier = Modifier.align(Alignment.Center).padding(top = topPad).padding(24.dp),
1394+ horizontalAlignment = Alignment.CenterHorizontally,
1395+ ) {
1396+ Text(
1397+ stringResource(R.string.load_messages_failed),
1398+ color = MaterialTheme.colorScheme.error,
1399+ )
1400+ Spacer(Modifier.size(12.dp))
1401+ Button(onClick = { vm.refreshMessages() }) {
1402+ Text(stringResource(R.string.retry))
1403+ }
1404+ // When the VM's retryWhen loop is actively retrying (loading is true
1405+ // on top of the error state), surface a small "retrying…" indicator so
1406+ // the user knows the system is working — without it, the static error
1407+ // + Retry button reads as "broken, tap to fix", and the user may not
1408+ // realize an automatic retry is already in progress.
1409+ if (loading) {
1410+ Spacer(Modifier.size(12.dp))
1411+ val retryingLabel = stringResource(R.string.retrying_ellipsis)
1412+ Row(verticalAlignment = Alignment.CenterVertically) {
1413+ CircularProgressIndicator(
1414+ Modifier.size(14.dp),
1415+ strokeWidth = 2.dp,
1416+ )
1417+ Text(
1418+ retryingLabel,
1419+ style = MaterialTheme.typography.labelSmall,
1420+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1421+ modifier = Modifier.padding(start = 6.dp),
1422+ )
1423+ }
1424+ }
1425+ }
1426+ } else if (messages.isEmpty() && running) {
1427+ // A run just started but no parts have arrived yet — the empty list with
1428+ // only the trailing "working…" row looks broken. Reuse the skeleton bubbles
1429+ // from the initial-load branch so the perceived start latency is lower and
1430+ // the first real assistant part pops into an already-shaped list.
1431+ ChatSkeleton(topPad = topPad)
1432+ } else if (messages.isEmpty()) {
1433+ EmptyConversation(
1434+ onSuggestion = {
1435+ vm.updateDraft(it)
1436+ // Focus the input so the user can edit the suggestion before
1437+ // sending, instead of having to manually tap the field.
1438+ runCatching { inputFocusRequester.requestFocus() }
1439+ },
1440+ modifier = Modifier.align(Alignment.Center).padding(top = topPad),
1441+ )
1442+ } else {
1443+ val lastMessageId = messages.lastOrNull()?.info?.id
1444+ // Text-to-speech for reading assistant replies aloud; shuts down when the
1445+ // message list leaves composition.
1446+ val tts = rememberTtsController()
1447+ val speakingMessageId by tts.speakingId
1448+ val ttsState by tts.state
1449+ // Provide the in-conversation search query to the markdown renderer so matching
1450+ // text spans are highlighted in-place (in addition to the focused-row highlight
1451+ // above). Scoped to the list so cleared search stops highlighting immediately.
1452+ val activeSearchQuery = searchQuery.takeIf { searchActive && it.isNotEmpty() }
1453+ CompositionLocalProvider(LocalSearchHighlight provides activeSearchQuery) {
1454+ LazyColumn(
1455+ state = listState,
1456+ modifier = Modifier.fillMaxSize(),
1457+ // Extra bottom padding reserves room for the jump-to-latest FAB so it
1458+ // never floats over the last message. Top padding grows when the
1459+ // connection banner is visible so it doesn't overlap the first message.
1460+ contentPadding = PaddingValues(
1461+ start = 16.dp,
1462+ top = topPad,
1463+ end = 16.dp,
1464+ bottom = 16.dp + NetworkConfig.chatListFabInsetDp.dp,
1465+ ),
1466+ // Compact spacing (8dp) vs the default 16dp — a power-user toggle to fit
1467+ // more conversation on screen without scrolling as much.
1468+ verticalArrangement = Arrangement.spacedBy(if (compactSpacing) 8.dp else 16.dp),
1469+ ) {
1470+ items(
1471+ items = listItems,
1472+ key = { it.key },
1473+ contentType = { it.contentType },
1474+ ) { item ->
1475+ // When in-conversation search is stepping through matches, highlight the
1476+ // focused one's row so the user can spot it at a glance among the filtered
1477+ // results (full in-text term highlighting would require threading the query
1478+ // through the markdown renderer; the row highlight + scroll-to-match covers
1479+ // the navigation use case desktop chat apps solve with next/prev).
1480+ val focusedMatchId = if (searchActive && searchQuery.isNotEmpty()) {
1481+ searchMessages.getOrNull(searchPos)?.info?.id
1482+ } else null
1483+ val isFocusedMatch = focusedMatchId != null &&
1484+ item is MessageListItem.Message &&
1485+ item.message.info.id == focusedMatchId
1486+ // Default placement animation so inserted/moved rows glide in. Skipped
1487+ // entirely under reduced motion so a streaming message growing in place
1488+ // doesn't animate on every token (and respects the a11y preference).
1489+ Box(
1490+ Modifier
1491+ .then(reducedMotionAnimateItem())
1492+ .then(
1493+ if (isFocusedMatch) {
1494+ Modifier.background(
1495+ MaterialTheme.colorScheme.secondaryContainer,
1496+ MaterialTheme.shapes.medium,
1497+ )
1498+ } else {
1499+ Modifier
1500+ },
1501+ ),
1502+ ) {
1503+ when (item) {
1504+ is MessageListItem.Separator -> DateSeparator(item.label)
1505+ is MessageListItem.Message -> {
1506+ val message = item.message
1507+ val messageId = message.info.id
1508+ // Only the last (streaming) message needs isRunning — it drives the
1509+ // reasoning-block spinner. Passing the live flag to every bubble
1510+ // makes all visible messages recompose whenever a run starts or stops.
1511+ val modelLabel = remember(message.info, models) {
1512+ (message.info as? soy.iko.opencode.data.model.AssistantMessage)
1513+ ?.let { resolveModelLabel(it, models) }
1514+ }
1515+ // Key agentLabel/quoteText on the message id (not message.parts) so
1516+ // the streaming message — whose `parts` list is replaced on every
1517+ // token — doesn't re-run filterIsInstance per token for these two
1518+ // derived labels. For a non-streaming message, `message.parts` is
1519+ // reference-stable anyway, so this is equivalent. For the streaming
1520+ // message the label updates as parts change via the message.info/
1521+ // parts reference change below — keyed on messageId + a parts-hash
1522+ // is overkill; agentLabel/quoteText only need to reflect the latest
1523+ // parts, and `remember(messageId, message.parts)` would re-key per
1524+ // token. Instead key on `messageId` alone and accept that the label
1525+ // lags by one recomposition for the streaming bubble (it stabilizes
1526+ // once the stream finishes). This is invisible in practice: agent
1527+ // label is set on the first part, quote text grows with text parts
1528+ // but is only consumed on a swipe (user-initiated, post-stream).
1529+ val agentLabel = remember(messageId) {
1530+ message.parts
1531+ .filterIsInstance<soy.iko.opencode.data.model.AgentPart>()
1532+ .firstOrNull { it.name.isNotBlank() }?.name
1533+ }
1534+ // Text used to drive swipe-to-reply (and pre-fill the quote). Empty for
1535+ // image/code-only messages, which then opt out of the swipe gesture.
1536+ val quoteText = remember(messageId) {
1537+ message.parts
1538+ .filterIsInstance<TextPart>()
1539+ .joinToString("\n\n") { it.text }
1540+ .trim()
1541+ .takeIf { it.isNotEmpty() }
1542+ }
1543+ // Swipe-start-to-end reveals a reply affordance and triggers quote-reply,
1544+ // snapping back (the message isn't dismissed — the quote fills the composer).
1545+ // Enabled for non-text messages too (image/tool-only), but the confirm
1546+ // callback gives a haptic without triggering reply — so the user gets a
1547+ // perceptible "can't quote this" signal instead of a silently dead gesture.
1548+ val cantQuoteMsg = stringResource(R.string.cannot_quote_this)
1549+ val undoLabel = stringResource(R.string.undo)
1550+ val quoteReplyLabel = stringResource(R.string.quote_reply_banner)
1551+ // Memoize the swipe confirmValueChange lambda on the message id + quoteText
1552+ // so it's stable across recompositions (a fresh lambda would force the
1553+ // SwipeToDismissBox to re-instantiate its state capture). quoteText is a
1554+ // derived value that's stable for non-streaming messages.
1555+ val swipeState = rememberSwipeToDismissBoxState(
1556+ confirmValueChange = remember(messageId, quoteText) {
1557+ { value ->
1558+ val replyValue = if (layoutDirection == LayoutDirection.Rtl) {
1559+ SwipeToDismissBoxValue.EndToStart
1560+ } else {
1561+ SwipeToDismissBoxValue.StartToEnd
1562+ }
1563+ if (value == replyValue) {
1564+ if (quoteText != null) {
1565+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
1566+ vm.quoteReply(quoteText)
1567+ runCatching { inputFocusRequester.requestFocus() }
1568+ scope.launch {
1569+ val res = snackbar.showSnackbar(
1570+ message = quoteReplyLabel,
1571+ actionLabel = undoLabel,
1572+ )
1573+ if (res == androidx.compose.material3.SnackbarResult.ActionPerformed) {
1574+ vm.cancelQuoteReply()
1575+ }
1576+ }
1577+ } else {
1578+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
1579+ scope.launch { snackbar.showSnackbar(cantQuoteMsg) }
1580+ }
1581+ }
1582+ false
1583+ }
1584+ },
1585+ )
1586+ // In RTL locales, mirror the swipe direction so the reply
1587+ // gesture reads naturally (swipe from the trailing edge
1588+ // toward the leading edge), matching how LTR users swipe
1589+ // left-to-right. Without this, RTL users had no reply swipe.
1590+ val replyFromStartToEnd = layoutDirection != LayoutDirection.Rtl
1591+ // Memoize every MessageBubble lambda on the message id so a recomposition
1592+ // triggered by a sibling (e.g. the streaming message's parts changing,
1593+ // which gives `listItems` a new identity) doesn't allocate fresh lambdas
1594+ // for the unchanged bubbles. MessageBubble is non-skippable (it takes
1595+ // many `(() -> Unit)?` params, which Compose treats as unstable), so
1596+ // without memoization every visible bubble recomposes on every token,
1597+ // defeating the @Immutable MessageWithParts reference-stability the
1598+ // reducer's Holder cache provides. This mirrors the SessionListScreen
1599+ // per-card lambda memoization pattern.
1600+ val onRevert = remember(messageId) { { vm.revertTo(messageId) } }
1601+ val onEdit = remember(messageId) {
1602+ { text: String ->
1603+ // editMessage reverts to before this message (hiding it and
1604+ // everything after); the revert banner surfaces the rewind
1605+ // with an Undo. Focus + scroll the composer into view like the
1606+ // Quote action so the prefilled draft isn't off-screen.
1607+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
1608+ vm.editMessage(messageId, text)
1609+ runCatching { inputFocusRequester.requestFocus() }
1610+ contentScope.launch {
1611+ runCatchingCancellable {
1612+ listState.animateScrollToItem(currentListItems.lastIndex.coerceAtLeast(0))
1613+ }
1614+ }
1615+ Unit
1616+ }
1617+ }
1618+ val onSpeak = remember(messageId) {
1619+ { text: String ->
1620+ if (!tts.toggle(messageId, text)) {
1621+ scope.launch { snackbar.showSnackbar(ttsUnavailableMsg) }
1622+ }
1623+ Unit
1624+ }
1625+ }
1626+ val onPause = remember { { tts.pause() } }
1627+ val onResume = remember { { tts.resume() } }
1628+ val onStop = remember { { tts.stop() } }
1629+ val onQuote = remember(messageId) {
1630+ { text: String ->
1631+ vm.quoteReply(text)
1632+ runCatching { inputFocusRequester.requestFocus() }
1633+ Unit
1634+ }
1635+ }
1636+ val onBranch = remember { { text: String -> vm.branchFrom(text); Unit } }
1637+ // Regenerate is only meaningful for a finished assistant reply
1638+ // (not the actively-streaming one), so gate it on !running.
1639+ val onRegenerate = if (!running) {
1640+ remember(messageId) { { vm.regenerate(messageId) } }
1641+ } else {
1642+ null
1643+ }
1644+ // Continue: resume a partial reply. Shown only for
1645+ // incomplete assistant messages (a partial reply that
1646+ // was aborted) and not while a run is active.
1647+ val canContinue = !running && message.info is AssistantMessage && !message.info.isComplete
1648+ val onContinue = if (canContinue) {
1649+ remember(messageId) { { vm.continueRun(messageId) } }
1650+ } else {
1651+ null
1652+ }
1653+ val messageFailed = optimisticStatuses[messageId] == true
1654+ val onRetry = if (messageFailed) {
1655+ remember(messageId) { { vm.retryOptimisticMessage(messageId); Unit } }
1656+ } else {
1657+ null
1658+ }
1659+ val onDismiss = if (messageFailed) {
1660+ remember(messageId) { { vm.dismissOptimistic(messageId) } }
1661+ } else {
1662+ null
1663+ }
1664+ val onShare = remember(messageId, sessionTitle, defaultShareSubject) {
1665+ {
1666+ // Per-message share: build a single-message Markdown
1667+ // transcript off-thread and fire ACTION_SEND, mirroring the
1668+ // whole-conversation share but scoped to just this message.
1669+ scope.launch {
1670+ val md = withContext(Dispatchers.Default) {
1671+ buildMessageMarkdown(message)
1672+ } ?: return@launch
1673+ val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
1674+ type = "text/markdown"
1675+ putExtra(android.content.Intent.EXTRA_SUBJECT, sessionTitle ?: defaultShareSubject)
1676+ putExtra(android.content.Intent.EXTRA_TEXT, md)
1677+ }
1678+ runCatchingCancellable { shareContext.startActivity(android.content.Intent.createChooser(send, shareContext.getString(R.string.share_message))) }
1679+ .onFailure { showToast(shareContext, shareContext.getString(R.string.no_share_app)) }
1680+ }
1681+ Unit
1682+ }
1683+ }
1684+ SwipeToDismissBox(
1685+ state = swipeState,
1686+ enableDismissFromEndToStart = !replyFromStartToEnd,
1687+ enableDismissFromStartToEnd = replyFromStartToEnd,
1688+ backgroundContent = {
1689+ Box(
1690+ Modifier.fillMaxSize().padding(horizontal = 16.dp),
1691+ contentAlignment = if (replyFromStartToEnd) Alignment.CenterStart else Alignment.CenterEnd,
1692+ ) {
1693+ Icon(
1694+ Icons.AutoMirrored.Filled.Reply,
1695+ contentDescription = null,
1696+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
1697+ )
1698+ }
1699+ },
1700+ ) {
1701+ MessageBubble(
1702+ message,
1703+ isRunning = running && messageId == lastMessageId,
1704+ imageContext = imageContext,
1705+ modelLabel = modelLabel,
1706+ agentLabel = agentLabel,
1707+ onOpenFile = onOpenFile,
1708+ onRevert = onRevert,
1709+ onEdit = onEdit,
1710+ onSpeak = onSpeak,
1711+ isSpeaking = messageId == speakingMessageId,
1712+ ttsState = if (messageId == speakingMessageId) ttsState else TtsState.IDLE,
1713+ onPause = onPause,
1714+ onResume = onResume,
1715+ onStop = onStop,
1716+ onQuote = onQuote,
1717+ onBranch = onBranch,
1718+ onRegenerate = onRegenerate,
1719+ onContinue = onContinue,
1720+ sendStatus = optimisticStatuses[messageId]?.let { failed ->
1721+ if (failed) soy.iko.opencode.ui.chat.MessageSendStatus.FAILED
1722+ else soy.iko.opencode.ui.chat.MessageSendStatus.SENDING
1723+ },
1724+ isEdited = message.info.time?.let { it.updated != null && it.updated != it.created } == true,
1725+ onRetry = onRetry,
1726+ onDismiss = onDismiss,
1727+ onShare = onShare,
1728+ isFirstOfSpeaker = item.isFirstOfSpeaker,
1729+ highlighted = focusedMessageId != null && messageId == focusedMessageId,
1730+ )
1731+ }
1732+ }
1733+ }
1734+ }
1735+ }
1736+ if (running) {
1737+ item(key = "__typing") {
1738+ val workingText = stringResource(R.string.working)
1739+ // When the SSE stream dropped mid-run, show a "reconnecting" label
1740+ // instead of the plain "working" text so the user understands the
1741+ // run is still active but the stream is reconnecting (not finished).
1742+ val reconnectingText = stringResource(R.string.reconnecting)
1743+ val interruptedLabel = if (streamInterrupted) reconnectingText else workingText
1744+ // Elapsed-since-run chip driven by the VM's runStartMs so the timer
1745+ // survives LazyColumn recycling — a local remember reset to 0:00 when
1746+ // the row was disposed and scrolled back into view. The VM stamps the
1747+ // start on the real false→true transition (send/run/init), not on
1748+ // SSE-reconnect relight, so reconnects don't restart the clock. Updates
1749+ // once per second; the value is decorative (the a11y label is the row's).
1750+ // The clock freezes when an abort is confirmed (aborting = true) so the
1751+ // user can see how long the run lasted before they stopped it, rather
1752+ // than the clock continuing to tick until SessionIdle clears _running.
1753+ val startMs by vm.runStartMs.collectAsStateWithLifecycle()
1754+ // Seed the initial value synchronously from startMs so the timer
1755+ // doesn't flash 0:00 when the __typing row is recycled by LazyColumn
1756+ // (scrolled out then back in): produceState's coroutine is cancelled on
1757+ // disposal and re-launched on re-entry, and a 0L initialValue would
1758+ // briefly show "0:00" before the producer's first iteration runs.
1759+ val elapsedMs by produceState(
1760+ initialValue = if (startMs > 0L) (System.currentTimeMillis() - startMs).coerceAtLeast(0L) else 0L,
1761+ startMs, aborting,
1762+ ) {
1763+ if (startMs == 0L) return@produceState
1764+ if (aborting) {
1765+ // Freeze at the last-computed value: one final snapshot then stop.
1766+ value = (System.currentTimeMillis() - startMs).coerceAtLeast(0L)
1767+ return@produceState
1768+ }
1769+ while (true) {
1770+ value = (System.currentTimeMillis() - startMs).coerceAtLeast(0L)
1771+ delay(1000)
1772+ }
1773+ }
1774+ val elapsedText = remember(elapsedMs) {
1775+ val total = elapsedMs / 1000
1776+ // Locale.US for stable digit formatting: on locales with
1777+ // non-ASCII digits (ar-/fa-) the default locale would render
1778+ // localized digits inside the Monospace block, breaking the
1779+ // fixed-width alignment the monospace family was chosen for.
1780+ String.format(java.util.Locale.US, "%d:%02d", total / 60, total % 60)
1781+ }
1782+ Row(
1783+ verticalAlignment = Alignment.CenterVertically,
1784+ // Animate this slot in/out so the working row doesn't pop in and
1785+ // jolt the message list when a run starts/stops — matching the
1786+ // AnimatedVisibility used by every other transient banner/FAB here.
1787+ modifier = Modifier
1788+ .then(reducedMotionAnimateItem())
1789+ .padding(vertical = 4.dp),
1790+ ) {
1791+ // Merge the indicator + label + clock into one TalkBack node so
1792+ // it's announced once (the working label, then the elapsed time)
1793+ // instead of "working… / working… / 0:14" with the label read
1794+ // twice. The Stop IconButton below stays separately focusable.
1795+ Row(
1796+ verticalAlignment = Alignment.CenterVertically,
1797+ modifier = Modifier.semantics(mergeDescendants = true) {
1798+ contentDescription = "$interruptedLabel $elapsedText"
1799+ },
1800+ ) {
1801+ // Dim the spinner when interrupted so the row reads as
1802+ // "paused/reconnecting" rather than actively progressing.
1803+ CircularProgressIndicator(
1804+ Modifier.size(16.dp),
1805+ strokeWidth = 2.dp,
1806+ color = if (streamInterrupted) MaterialTheme.colorScheme.onSurfaceVariant
1807+ else MaterialTheme.colorScheme.primary,
1808+ )
1809+ Text(interruptedLabel, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
1810+ Spacer(Modifier.size(8.dp))
1811+ Icon(
1812+ Icons.Filled.Schedule,
1813+ contentDescription = null,
1814+ modifier = Modifier.size(14.dp),
1815+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
1816+ )
1817+ Text(
1818+ elapsedText,
1819+ style = MaterialTheme.typography.labelMedium,
1820+ fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
1821+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1822+ modifier = Modifier.padding(start = 4.dp),
1823+ )
1824+ }
1825+ // A trailing Stop on the working row so the run can be aborted
1826+ // without scrolling back to the composer (which may be off-screen
1827+ // while the user reads the streaming output up-thread).
1828+ Spacer(Modifier.weight(1f))
1829+ val stopLabel = stringResource(R.string.stop)
1830+ IconButton(
1831+ onClick = { vm.abort() },
1832+ modifier = Modifier.semantics { contentDescription = stopLabel },
1833+ ) {
1834+ Icon(Icons.Filled.Stop, contentDescription = null)
1835+ }
1836+ }
1837+ }
1838+ }
1839+ }
1840+ }
1841+ }
1842+ }
1843+ // Jump-to-latest affordance when the user has scrolled away during a stream.
1844+ val fabMotion = rememberVisibilityTransitions()
1845+ AnimatedVisibility(
1846+ visible = !isPinnedToBottom && listItems.isNotEmpty(),
1847+ enter = fabMotion.enter,
1848+ exit = fabMotion.exit,
1849+ // Inset by sideMargin on wide screens so the FAB tracks the capped list's
1850+ // edge instead of floating at the screen edge (visually detached on tablets).
1851+ modifier = Modifier.align(Alignment.BottomEnd).padding(end = sideMargin),
1852+ ) {
1853+ // Badge the FAB when new content arrived while the user was scrolled up, so
1854+ // they know there's something new to jump to (cleared once back at bottom).
1855+ BadgedBox(
1856+ badge = {
1857+ if (newContentCount > 0) {
1858+ // Merge the count into the badge announcement so TalkBack reads
1859+ // "5 new messages" instead of the bare label and a separate number.
1860+ val newContentLabel = pluralStringResource(
1861+ R.plurals.plurals_new_messages,
1862+ newContentCount,
1863+ newContentCount,
1864+ )
1865+ Badge(
1866+ modifier = Modifier.semantics {
1867+ contentDescription = newContentLabel
1868+ // Announce new-content counts as they change so a
1869+ // TalkBack user hears "5 new messages" without having
1870+ // to focus the FAB. Polite so it doesn't interrupt an
1871+ // in-progress utterance (e.g. streaming token reads).
1872+ liveRegion = androidx.compose.ui.semantics.LiveRegionMode.Polite
1873+ },
1874+ ) {
1875+ Text(
1876+ if (newContentCount > 99) "99+" else newContentCount.toString(),
1877+ modifier = Modifier.semantics { invisibleToUser() },
1878+ )
1879+ }
1880+ }
1881+ },
1882+ modifier = Modifier.padding(end = 16.dp, bottom = 16.dp),
1883+ ) {
1884+ ExtendedFloatingActionButton(
1885+ onClick = {
1886+ newContentCount = 0
1887+ if (listItems.isNotEmpty()) {
1888+ // Scroll to the effective last index, including the
1889+ // trailing "__typing" row when a run is active.
1890+ val target = if (running) listItems.size else listItems.lastIndex
1891+ contentScope.launch { runCatchingCancellable { listState.animateScrollToItem(target) } }
1892+ }
1893+ },
1894+ // contentDescription null: the visible "Latest" text already labels
1895+ // the FAB, so a description here makes TalkBack announce "Latest" twice.
1896+ icon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = null) },
1897+ text = { Text(stringResource(R.string.latest)) },
1898+ )
1899+ }
1900+ }
1901+ // Scroll-to-top affordance: a small FAB at the start edge, visible when the
1902+ // user has scrolled down past the first few messages. Symmetric to the
1903+ // jump-to-latest FAB, for long conversations where manual fling back is tedious.
1904+ val scrolledDown by remember {
1905+ derivedStateOf { listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 200 }
1906+ }
1907+ AnimatedVisibility(
1908+ visible = scrolledDown && listItems.size > 4,
1909+ enter = fabMotion.enter,
1910+ exit = fabMotion.exit,
1911+ modifier = Modifier.align(Alignment.BottomStart).padding(start = sideMargin),
1912+ ) {
1913+ SmallFloatingActionButton(
1914+ onClick = {
1915+ contentScope.launch { runCatchingCancellable { listState.animateScrollToItem(0) } }
1916+ },
1917+ modifier = Modifier.padding(start = 16.dp, bottom = 16.dp),
1918+ ) {
1919+ Icon(
1920+ Icons.Filled.KeyboardArrowUp,
1921+ contentDescription = stringResource(R.string.scroll_to_top),
1922+ )
1923+ }
1924+ }
1925+ // In-conversation search bar. Overlays the list so typing filters messages in
1926+ // place; the match count + a no-matches note give immediate feedback.
1927+ val searchMotion = rememberVisibilityTransitions()
1928+ AnimatedVisibility(
1929+ visible = searchActive,
1930+ enter = searchMotion.enter,
1931+ exit = searchMotion.exit,
1932+ // Inset by sideMargin on wide screens so the search bar spans the capped
1933+ // list's width, not the full screen width.
1934+ modifier = Modifier.align(Alignment.TopCenter).padding(horizontal = sideMargin),
1935+ ) {
1936+ Surface(
1937+ tonalElevation = 3.dp,
1938+ modifier = Modifier
1939+ .padding(horizontal = 12.dp, vertical = 4.dp)
1940+ .fillMaxWidth()
1941+ .onSizeChanged { searchbarHeightPx = it.height },
1942+ ) {
1943+ // Focus the search field when the bar appears (from the overflow menu's
1944+ // "Find in conversation" or Ctrl+F), so the user can start typing
1945+ // immediately. Runs after the search field enters the composition.
1946+ LaunchedEffect(searchActive) {
1947+ if (searchActive) runCatching { searchFocusRequester.requestFocus() }
1948+ }
1949+ Row(
1950+ modifier = Modifier.padding(start = 8.dp, end = 4.dp),
1951+ verticalAlignment = Alignment.CenterVertically,
1952+ ) {
1953+ OutlinedTextField(
1954+ value = chatSearch,
1955+ onValueChange = { chatSearch = it },
1956+ modifier = Modifier.weight(1f).focusRequester(searchFocusRequester),
1957+ placeholder = { Text(stringResource(R.string.search_in_conversation)) },
1958+ singleLine = true,
1959+ leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
1960+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
1961+ )
1962+ // Match counter: "N of M" where N is the focused match (1-based) and
1963+ // M is the total matching messages. Only shown when there's a query.
1964+ val matchCount = searchMessages.size
1965+ Text(
1966+ if (searchQuery.isNotEmpty() && matchCount > 0) {
1967+ stringResource(R.string.search_match_count, searchPos + 1, matchCount)
1968+ } else if (searchQuery.isNotEmpty()) {
1969+ stringResource(R.string.search_no_matches)
1970+ } else "",
1971+ style = MaterialTheme.typography.labelSmall,
1972+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1973+ modifier = Modifier
1974+ .padding(horizontal = 4.dp)
1975+ // Live region so a TalkBack user hears "3 of 12" / "No
1976+ // matches" announce as they step through results, not just
1977+ // the sighted user seeing the counter update.
1978+ .semantics { liveRegion = androidx.compose.ui.semantics.LiveRegionMode.Polite },
1979+ )
1980+ // Step through matches (Previous / Next), disabled when there are
1981+ // fewer than two to navigate. Wrap around at the ends so repeated
1982+ // taps cycle through every hit.
1983+ val prevLabel = stringResource(R.string.find_previous)
1984+ val nextLabel = stringResource(R.string.find_next)
1985+ IconButton(
1986+ onClick = {
1987+ if (matchCount > 0) {
1988+ searchPos = (searchPos - 1 + matchCount) % matchCount
1989+ }
1990+ },
1991+ enabled = matchCount > 1,
1992+ ) {
1993+ Icon(Icons.Filled.KeyboardArrowUp, contentDescription = prevLabel)
1994+ }
1995+ IconButton(
1996+ onClick = {
1997+ if (matchCount > 0) {
1998+ searchPos = (searchPos + 1) % matchCount
1999+ }
2000+ },
2001+ enabled = matchCount > 1,
2002+ ) {
2003+ Icon(Icons.Filled.KeyboardArrowDown, contentDescription = nextLabel)
2004+ }
2005+ IconButton(onClick = {
2006+ searchActive = false
2007+ chatSearch = ""
2008+ }) {
2009+ Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
2010+ }
2011+ }
2012+ }
2013+ }
2014+ // When a search yields no matches (but the conversation isn't empty), say so
2015+ // instead of showing a blank list.
2016+ if (searchActive && searchQuery.isNotEmpty() && searchMessages.isEmpty() && messages.isNotEmpty()) {
2017+ Text(
2018+ stringResource(R.string.search_no_matches),
2019+ style = MaterialTheme.typography.bodyMedium,
2020+ color = MaterialTheme.colorScheme.onSurfaceVariant,
2021+ modifier = Modifier.align(Alignment.Center),
2022+ )
2023+ }
2024+ }
2025+ }
2026+ }
2027+ }
2028+
2029+ if (showExitConfirm) {
2030+ AlertDialog(
2031+ onDismissRequest = { showExitConfirm = false },
2032+ title = { Text(stringResource(R.string.stop_and_exit_title)) },
2033+ text = { Text(stringResource(R.string.stop_and_exit_text)) },
2034+ confirmButton = {
2035+ TextButton(onClick = {
2036+ showExitConfirm = false
2037+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
2038+ vm.abort()
2039+ onBack()
2040+ }) { Text(stringResource(R.string.stop_and_exit)) }
2041+ },
2042+ dismissButton = {
2043+ TextButton(onClick = { showExitConfirm = false }) { Text(stringResource(R.string.stay)) }
2044+ },
2045+ )
2046+ }
2047+
2048+ if (showSummarizeConfirm) {
2049+ AlertDialog(
2050+ onDismissRequest = { showSummarizeConfirm = false },
2051+ title = { Text(stringResource(R.string.summarize_confirm_title)) },
2052+ text = { Text(stringResource(R.string.summarize_confirm_text)) },
2053+ confirmButton = {
2054+ TextButton(onClick = {
2055+ showSummarizeConfirm = false
2056+ vm.summarize()
2057+ }) { Text(stringResource(R.string.action_continue)) }
2058+ },
2059+ dismissButton = {
2060+ TextButton(onClick = { showSummarizeConfirm = false }) { Text(stringResource(R.string.cancel)) }
2061+ },
2062+ )
2063+ }
2064+
2065+ if (showInitConfirm) {
2066+ AlertDialog(
2067+ onDismissRequest = { showInitConfirm = false },
2068+ title = { Text(stringResource(R.string.init_confirm_title)) },
2069+ text = { Text(stringResource(R.string.init_confirm_text)) },
2070+ confirmButton = {
2071+ TextButton(onClick = {
2072+ showInitConfirm = false
2073+ vm.initProject()
2074+ }) { Text(stringResource(R.string.action_continue)) }
2075+ },
2076+ dismissButton = {
2077+ TextButton(onClick = { showInitConfirm = false }) { Text(stringResource(R.string.cancel)) }
2078+ },
2079+ )
2080+ }
2081+
2082+ if (showRenameDialog) {
2083+ RenameSessionChatDialog(
2084+ initialTitle = sessionTitle ?: "",
2085+ onDismiss = { showRenameDialog = false },
2086+ onConfirm = { newName ->
2087+ showRenameDialog = false
2088+ vm.renameSession(newName)
2089+ },
2090+ )
2091+ }
2092+
2093+ if (showDeleteDialog) {
2094+ AlertDialog(
2095+ onDismissRequest = { showDeleteDialog = false },
2096+ title = { Text(stringResource(R.string.delete_session_chat_title)) },
2097+ text = { Text(stringResource(R.string.delete_session_chat_text, sessionTitle ?: sessionLabel)) },
2098+ confirmButton = {
2099+ TextButton(onClick = {
2100+ showDeleteDialog = false
2101+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
2102+ vm.deleteSession()
2103+ }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
2104+ },
2105+ dismissButton = {
2106+ TextButton(onClick = { showDeleteDialog = false }) { Text(stringResource(R.string.cancel)) }
2107+ },
2108+ )
2109+ }
2110+
2111+ if (showShellDialog) {
2112+ ShellCommandDialog(
2113+ onDismiss = { showShellDialog = false },
2114+ onRun = { cmd ->
2115+ showShellDialog = false
2116+ vm.runShell(cmd)
2117+ },
2118+ )
2119+ }
2120+
2121+ if (showModelPicker) {
2122+ // Resolve persisted recent composite keys to the live ModelOption entries they refer
2123+ // to, dropping any whose model is no longer offered by the server. Ordered by
2124+ // recency (head of the store first).
2125+ val recentOptions = remember(models, recentModelEntries) {
2126+ val byKey = models.associateBy { it.providerID to it.modelID }
2127+ recentModelEntries.mapNotNull { entry ->
2128+ container.recentModelsStore.split(entry)?.let { (p, m) -> byKey[p to m] }
2129+ }
2130+ }
2131+ // Resolve snackbar strings in the composable scope; the onSetPreferredModel lambda
2132+ // below isn't @Composable, so stringResource can't be called inside it.
2133+ val defaultModelClearedMsg = stringResource(R.string.default_model_cleared)
2134+ val defaultModelSetFmt = stringResource(R.string.default_model_set, "%s")
2135+ ModelPickerSheet(
2136+ options = models,
2137+ selected = selectedModel,
2138+ loading = modelsLoading,
2139+ error = modelsError,
2140+ onSelect = {
2141+ vm.selectModel(it)
2142+ container.recentModelsStore.add(it.providerID, it.modelID)
2143+ },
2144+ onRetry = { vm.reloadModels() },
2145+ onDismiss = { showModelPicker = false },
2146+ preferredModelId = preferredModelId,
2147+ onSetPreferredModel = { id ->
2148+ scope.launch {
2149+ runCatchingCancellable { container.settingsStore.setPreferredModelId(id) }
2150+ // Confirm the change with a snackbar so the user sees it took effect —
2151+ // the toggle's label flip can be missed, especially when clearing.
2152+ val msg = if (id.isEmpty()) {
2153+ defaultModelClearedMsg
2154+ } else {
2155+ val label = models.firstOrNull { it.modelID == id }?.modelLabel ?: id
2156+ defaultModelSetFmt.format(label)
2157+ }
2158+ snackbar.showSnackbar(msg)
2159+ }
2160+ },
2161+ recent = recentOptions,
2162+ )
2163+ }
2164+
2165+ if (showAgentPicker) {
2166+ AgentPickerSheet(
2167+ agents = agents,
2168+ selected = selectedAgent,
2169+ loading = agentsLoading,
2170+ error = agentsError,
2171+ onSelect = { vm.selectAgent(it?.name) },
2172+ onRetry = { vm.reloadAgents() },
2173+ onDismiss = { showAgentPicker = false },
2174+ )
2175+ }
2176+
2177+ if (showCommandPicker) {
2178+ CommandPickerSheet(
2179+ commands = commands,
2180+ loading = commandsLoading,
2181+ error = commandsError,
2182+ onSelect = { vm.runCommand(it) },
2183+ onRetry = { vm.reloadCommands() },
2184+ onDismiss = { showCommandPicker = false },
2185+ )
2186+ }
2187+
2188+ pendingPermission?.let { permission ->
2189+ PermissionDialog(
2190+ permission = permission,
2191+ position = permissionProgress.position,
2192+ total = permissionProgress.total,
2193+ onRespond = { response -> vm.respondPermission(permission, response) },
2194+ // On auto-reject timeout, post a notification so a returning user finds a
2195+ // persistent record of why the run stopped (the dialog itself is gone).
2196+ // Gated by the user's notification setting (notifPermission).
2197+ onAutoReject = {
2198+ val conn = activeConnection
2199+ val ctx = appContext
2200+ val title = sessionTitle ?: sessionId
2201+ if (notifPermission) {
2202+ soy.iko.opencode.notification.SessionNotifications.postPermissionAutoRejected(
2203+ ctx, sessionId, title, conn?.profile?.id,
2204+ )
2205+ }
2206+ },
2207+ )
2208+ }
2209+
2210+ if (showPalette) {
2211+ CommandPalette(actions = paletteActions, onDismiss = { showPalette = false })
2212+ }
2213+
2214+ if (showShortcutsDialog) {
2215+ // Surfaces the hardware-keyboard shortcuts (Ctrl+K palette, Escape stop, Enter send)
2216+ // that are otherwise wired but undiscoverable. A simple key/value list; Enter behavior
2217+ // depends on the "Send on Enter" setting, noted inline.
2218+ AlertDialog(
2219+ onDismissRequest = { showShortcutsDialog = false },
2220+ title = { Text(stringResource(R.string.keyboard_shortcuts_title)) },
2221+ text = {
2222+ Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
2223+ ShortcutRow("Ctrl + K", stringResource(R.string.shortcut_open_palette))
2224+ ShortcutRow("Esc", stringResource(R.string.shortcut_stop_run))
2225+ ShortcutRow("Esc", stringResource(R.string.shortcut_close))
2226+ ShortcutRow("Enter", stringResource(R.string.shortcut_send))
2227+ ShortcutRow("Shift + Enter / Ctrl + Enter", stringResource(R.string.shortcut_newline))
2228+ }
2229+ },
2230+ confirmButton = {
2231+ TextButton(onClick = { showShortcutsDialog = false }) { Text(stringResource(R.string.action_continue)) }
2232+ },
2233+ )
2234+ }
2235+}
2236+
2237+@Composable
2238+private fun ShortcutRow(keys: String, desc: String) {
2239+ Row(verticalAlignment = Alignment.CenterVertically) {
2240+ Text(
2241+ keys,
2242+ style = MaterialTheme.typography.labelLarge,
2243+ fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
2244+ modifier = Modifier.widthIn(min = 160.dp),
2245+ )
2246+ Text(
2247+ desc,
2248+ style = MaterialTheme.typography.bodyMedium,
2249+ color = MaterialTheme.colorScheme.onSurfaceVariant,
2250+ )
2251+ }
2252+}
2253+
2254+/**
2255+ * Slash-command autocomplete: when the draft starts with "/", show a filtered dropdown
2256+ * of matching commands above the composer. Tapping a command fires it immediately.
2257+ * Extracted from [ChatInputBar] to keep that function's cyclomatic complexity under
2258+ * the detekt threshold. Suppressed while running or when the command catalog is empty.
2259+ */
2260+@Composable
2261+private fun SlashCommandAutocomplete(
2262+ value: String,
2263+ commands: List<Command>,
2264+ running: Boolean,
2265+ onRunCommand: (Command) -> Unit,
2266+) {
2267+ if (running || commands.isEmpty()) return
2268+ val slashQuery = remember(value) {
2269+ if (value.startsWith("/")) value.removePrefix("/") else null
2270+ } ?: return
2271+ val matchingCommands = remember(slashQuery, commands) {
2272+ commands.filter { cmd ->
2273+ slashQuery.isEmpty() ||
2274+ cmd.name.contains(slashQuery, ignoreCase = true) ||
2275+ (cmd.description?.contains(slashQuery, ignoreCase = true) == true)
2276+ }.take(8)
2277+ }
2278+ if (matchingCommands.isNotEmpty()) {
2279+ CommandAutocompleteDropdown(
2280+ commands = matchingCommands,
2281+ onPick = onRunCommand,
2282+ )
2283+ }
2284+}
2285+
2286+/**
2287+ * Slash-command autocomplete dropdown shown above the composer when the draft starts
2288+ * with "/". Renders up to 8 matching commands as a scrollable, elevated surface so the
2289+ * user can discover and invoke commands by typing the conventional "/" prefix instead
2290+ * of opening the overflow menu / command palette. Tapping a row fires the command.
2291+ */
2292+@Composable
2293+private fun CommandAutocompleteDropdown(
2294+ commands: List<Command>,
2295+ onPick: (Command) -> Unit,
2296+) {
2297+ val haptics = LocalHapticFeedback.current
2298+ Surface(
2299+ color = MaterialTheme.colorScheme.surface,
2300+ tonalElevation = 3.dp,
2301+ shape = MaterialTheme.shapes.medium,
2302+ modifier = Modifier
2303+ .fillMaxWidth()
2304+ .padding(horizontal = 8.dp, vertical = 4.dp)
2305+ .heightIn(max = 240.dp),
2306+ ) {
2307+ LazyColumn {
2308+ items(
2309+ count = commands.size,
2310+ key = { commands[it].name },
2311+ ) { i ->
2312+ val cmd = commands[i]
2313+ Row(
2314+ modifier = Modifier
2315+ .fillMaxWidth()
2316+ .clickable(role = Role.Button) {
2317+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
2318+ onPick(cmd)
2319+ }
2320+ .padding(horizontal = 16.dp, vertical = 10.dp),
2321+ verticalAlignment = Alignment.CenterVertically,
2322+ horizontalArrangement = Arrangement.spacedBy(12.dp),
2323+ ) {
2324+ Text(
2325+ "/${cmd.name}",
2326+ style = MaterialTheme.typography.labelLarge,
2327+ fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
2328+ color = MaterialTheme.colorScheme.primary,
2329+ )
2330+ cmd.description?.takeIf { it.isNotBlank() }?.let { desc ->
2331+ Text(
2332+ desc,
2333+ style = MaterialTheme.typography.bodySmall,
2334+ color = MaterialTheme.colorScheme.onSurfaceVariant,
2335+ maxLines = 1,
2336+ overflow = TextOverflow.Ellipsis,
2337+ )
2338+ }
2339+ }
2340+ }
2341+ }
2342+ }
2343+}
2344+
2345+@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
2346+@Suppress("CyclomaticComplexMethod")
2347+@Composable
2348+private fun ChatInputBar(
2349+ value: String,
2350+ onValueChange: (String) -> Unit,
2351+ running: Boolean,
2352+ aborting: Boolean,
2353+ enabled: Boolean,
2354+ sendOnEnter: Boolean,
2355+ onSend: () -> Unit,
2356+ onAbort: () -> Unit,
2357+ queuedFollowUp: String?,
2358+ onQueueFollowUp: (String) -> Unit,
2359+ onCancelQueue: () -> Unit,
2360+ focusRequester: androidx.compose.ui.focus.FocusRequester,
2361+ attachments: List<PendingAttachment>,
2362+ staging: Boolean,
2363+ stagingFileCount: Int,
2364+ onRemoveAttachment: (String) -> Unit,
2365+ onPickPhoto: () -> Unit,
2366+ onPickFile: () -> Unit,
2367+ onCamera: () -> Unit,
2368+ onVoice: () -> Unit,
2369+ onPasteImage: (List<Uri>) -> Unit,
2370+ commands: List<Command> = emptyList(),
2371+ onRunCommand: (Command) -> Unit = {},
2372+) {
2373+ // Sendable when there's text OR at least one attachment (an image-only prompt is valid).
2374+ val hasContent = value.isNotBlank() || attachments.isNotEmpty()
2375+ val context = LocalContext.current
2376+ // Full-screen editor state: opened when the user wants more space for a long prompt.
2377+ var showFullScreenEditor by rememberSaveable { mutableStateOf(false) }
2378+ Surface(tonalElevation = 3.dp, modifier = Modifier.imePadding()) {
2379+ Column(modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets.navigationBars)) {
2380+ // A queued follow-up replaces the Stop button with a "queued" chip so the
2381+ // user sees their message will be sent when the run finishes, and can cancel.
2382+ if (queuedFollowUp != null) {
2383+ val editLabel = stringResource(R.string.edit)
2384+ Row(
2385+ modifier = Modifier
2386+ .fillMaxWidth()
2387+ .padding(start = 12.dp, top = 6.dp, end = 4.dp),
2388+ verticalAlignment = Alignment.CenterVertically,
2389+ ) {
2390+ // Tapping the chip reloads the queued text into the draft (and clears the
2391+ // queue) so a typo spotted while queuing can be fixed without retyping.
2392+ Column(
2393+ modifier = Modifier
2394+ .weight(1f)
2395+ .clip(MaterialTheme.shapes.small)
2396+ .clickable(role = Role.Button, onClickLabel = editLabel) {
2397+ onValueChange(queuedFollowUp)
2398+ onCancelQueue()
2399+ }
2400+ .padding(vertical = 4.dp),
2401+ ) {
2402+ Text(
2403+ stringResource(R.string.queued),
2404+ style = MaterialTheme.typography.labelSmall,
2405+ color = MaterialTheme.colorScheme.onSurfaceVariant,
2406+ )
2407+ Text(
2408+ queuedFollowUp,
2409+ style = MaterialTheme.typography.bodySmall,
2410+ color = MaterialTheme.colorScheme.onSurfaceVariant,
2411+ maxLines = 1,
2412+ overflow = TextOverflow.Ellipsis,
2413+ )
2414+ }
2415+ TextButton(onClick = onCancelQueue, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp)) {
2416+ Text(stringResource(R.string.cancel))
2417+ }
2418+ }
2419+ }
2420+ // Staged attachments: horizontally-scrollable thumbnails/chips, each removable.
2421+ AttachmentStrip(attachments, onRemove = onRemoveAttachment, staging = staging, stagingFileCount = stagingFileCount)
2422+ // Slash-command autocomplete: extracted to a helper to keep this function's
2423+ // complexity under the detekt threshold. Returns no-op when the draft doesn't
2424+ // start with "/" or the command catalog is empty.
2425+ SlashCommandAutocomplete(
2426+ value = value,
2427+ commands = commands,
2428+ running = running,
2429+ onRunCommand = onRunCommand,
2430+ )
2431+ Row(
2432+ modifier = Modifier.fillMaxWidth().padding(8.dp),
2433+ verticalAlignment = Alignment.Bottom,
2434+ ) {
2435+ // Attach menu: photo picker, camera, or any file.
2436+ var showAttachMenu by remember { mutableStateOf(false) }
2437+ Box {
2438+ IconButton(onClick = { showAttachMenu = true }, enabled = enabled) {
2439+ Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.add_attachment))
2440+ }
2441+ DropdownMenu(expanded = showAttachMenu, onDismissRequest = { showAttachMenu = false }) {
2442+ DropdownMenuItem(
2443+ leadingIcon = { Icon(Icons.Filled.PhotoLibrary, contentDescription = null) },
2444+ text = { Text(stringResource(R.string.attach_photo)) },
2445+ onClick = { showAttachMenu = false; onPickPhoto() },
2446+ )
2447+ DropdownMenuItem(
2448+ leadingIcon = { Icon(Icons.Filled.PhotoCamera, contentDescription = null) },
2449+ text = { Text(stringResource(R.string.attach_camera)) },
2450+ onClick = { showAttachMenu = false; onCamera() },
2451+ )
2452+ DropdownMenuItem(
2453+ leadingIcon = { Icon(Icons.Filled.Description, contentDescription = null) },
2454+ text = { Text(stringResource(R.string.attach_file)) },
2455+ onClick = { showAttachMenu = false; onPickFile() },
2456+ )
2457+ }
2458+ }
2459+ // Voice dictation: appends recognized speech to the draft. Hidden (not just
2460+ // disabled) when the device has no speech-recognition activity, so a user
2461+ // without a voice app doesn't get a button that only fails noisily on tap.
2462+ val voiceAvailable = remember {
2463+ val pm = context.packageManager
2464+ val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH)
2465+ pm.queryIntentActivities(intent, 0).isNotEmpty()
2466+ }
2467+ if (voiceAvailable) {
2468+ IconButton(onClick = onVoice, enabled = enabled) {
2469+ Icon(Icons.Filled.Mic, contentDescription = stringResource(R.string.voice_input))
2470+ }
2471+ }
2472+ // Expand to a full-screen editor for long prompts (the inline field caps at 6 lines).
2473+ IconButton(onClick = { showFullScreenEditor = true }, enabled = enabled) {
2474+ Icon(Icons.Filled.Expand, contentDescription = stringResource(R.string.expand_editor))
2475+ }
2476+ OutlinedTextField(
2477+ value = value,
2478+ onValueChange = { v ->
2479+ // Cap input length so a huge paste can't stall the UI. Shares are
2480+ // capped separately in MainActivity; this guards typed/pasted drafts.
2481+ onValueChange(v.take(NetworkConfig.maxDraftLengthChars))
2482+ },
2483+ modifier = Modifier
2484+ .weight(1f)
2485+ .focusRequester(focusRequester)
2486+ .testTag("chat_input")
2487+ // Intercept image content pasted/dropped into the field (keyboard paste
2488+ // of a copied screenshot, drag from Files, etc.) and route it through the
2489+ // same staging path as the attach menu. Non-image content (text) falls
2490+ // through unchanged so normal typing/paste works.
2491+ .contentReceiver(
2492+ remember {
2493+ androidx.compose.foundation.content.ReceiveContentListener { transferable ->
2494+ val clipData = transferable.clipEntry?.clipData
2495+ if (clipData != null) {
2496+ val imageUris = (0 until clipData.itemCount).mapNotNull { i ->
2497+ val item = clipData.getItemAt(i)
2498+ item.uri?.takeIf { uri ->
2499+ item.text == null && uri.toString().let { s ->
2500+ s.startsWith("content:") || s.startsWith("file:")
2501+ }
2502+ }
2503+ }
2504+ if (imageUris.isNotEmpty()) {
2505+ onPasteImage(imageUris)
2506+ }
2507+ }
2508+ transferable
2509+ }
2510+ },
2511+ )
2512+ .onPreviewKeyEvent { event ->
2513+ if (event.type != KeyEventType.KeyDown || event.key != Key.Enter) return@onPreviewKeyEvent false
2514+ // With "Send on Enter" on, Enter sends (Shift+Enter newlines).
2515+ // With it off, Enter inserts a newline and Ctrl+Enter sends.
2516+ val send = enterShouldSend(
2517+ enabled = enabled,
2518+ hasContent = hasContent,
2519+ sendOnEnter = sendOnEnter,
2520+ shift = event.isShiftPressed,
2521+ ctrl = event.isCtrlPressed,
2522+ )
2523+ if (send) {
2524+ if (running) onQueueFollowUp(value) else onSend()
2525+ true
2526+ } else {
2527+ false
2528+ }
2529+ },
2530+ // While a run is active a typed message only queues (sends after the run), so
2531+ // the placeholder says so instead of the plain "Message…".
2532+ placeholder = {
2533+ Text(stringResource(if (running) R.string.composer_hint_running else R.string.message_placeholder))
2534+ },
2535+ enabled = enabled,
2536+ maxLines = 6,
2537+ // Show a "N / max" countdown once the draft crosses a high fraction of
2538+ // the cap, so the user knows a paste is about to be truncated instead
2539+ // of being silently cut off. Hidden for normal short prompts to avoid
2540+ // clutter under a typical one-line message.
2541+ supportingText = {
2542+ val threshold = (NetworkConfig.maxDraftLengthChars * NetworkConfig.draftCountdownThresholdFraction).toInt()
2543+ if (value.length >= threshold) {
2544+ Text(
2545+ stringResource(
2546+ R.string.draft_chars_remaining,
2547+ NetworkConfig.maxDraftLengthChars - value.length,
2548+ NetworkConfig.maxDraftLengthChars,
2549+ ),
2550+ style = MaterialTheme.typography.labelSmall,
2551+ color = if (value.length >= NetworkConfig.maxDraftLengthChars) MaterialTheme.colorScheme.error
2552+ else MaterialTheme.colorScheme.onSurfaceVariant,
2553+ )
2554+ } else if (value.lines().size > 6) {
2555+ // The inline field caps at 6 lines; once the draft crosses that,
2556+ // hint that a bigger full-screen editor exists (the Expand icon
2557+ // above is easy to miss among the icon row). A tappable label
2558+ // would be ideal, but a plain hint is enough to draw attention.
2559+ Text(
2560+ stringResource(R.string.open_full_editor_hint),
2561+ style = MaterialTheme.typography.labelSmall,
2562+ color = MaterialTheme.colorScheme.onSurfaceVariant,
2563+ )
2564+ }
2565+ },
2566+ keyboardOptions = KeyboardOptions(
2567+ // When "Send on Enter" is on, expose ImeAction.Send so the soft
2568+ // keyboard shows a send key — matching the setting's intent and
2569+ // giving touch-only users a keyboard send affordance. When off,
2570+ // ImeAction.Default keeps the enter/newline key for multi-line input.
2571+ imeAction = if (sendOnEnter) ImeAction.Send else ImeAction.Default,
2572+ capitalization = KeyboardCapitalization.Sentences,
2573+ ),
2574+ keyboardActions = KeyboardActions(
2575+ onSend = {
2576+ // Mirror the hardware-Enter handler: during a run the soft-keyboard
2577+ // send key must queue the follow-up, not silently drop it. vm.send()
2578+ // returns false while a run is active (beginRun() rejects), so calling
2579+ // onSend() directly here would lose the typed message with no feedback
2580+ // for touch-only users (no hardware Enter path available).
2581+ if (enabled && hasContent) {
2582+ if (running) onQueueFollowUp(value) else onSend()
2583+ }
2584+ },
2585+ ),
2586+ )
2587+ ComposerTrailingButton(
2588+ running = running,
2589+ aborting = aborting,
2590+ canSend = enabled && hasContent,
2591+ canQueue = enabled && value.isNotBlank(),
2592+ onSend = onSend,
2593+ onAbort = onAbort,
2594+ onQueue = { onQueueFollowUp(value) },
2595+ )
2596+ }
2597+ }
2598+ if (showFullScreenEditor) {
2599+ FullScreenEditor(
2600+ value = value,
2601+ onValueChange = onValueChange,
2602+ onDismiss = { showFullScreenEditor = false },
2603+ onSend = {
2604+ showFullScreenEditor = false
2605+ // Mirror the inline composer's behavior: while a run is active a send
2606+ // can't go immediately, so queue the follow-up instead of silently
2607+ // dropping it (vm.send() returns false during a run, which previously
2608+ // lost the typed message with no feedback).
2609+ if (running) onQueueFollowUp(value) else onSend()
2610+ },
2611+ canSend = enabled && hasContent,
2612+ running = running,
2613+ sendOnEnter = sendOnEnter,
2614+ attachments = attachments,
2615+ onRemoveAttachment = onRemoveAttachment,
2616+ staging = staging,
2617+ stagingFileCount = stagingFileCount,
2618+ )
2619+ }
2620+ }
2621+}
2622+
2623+/** Full-screen editor dialog for composing long prompts. Gives the full viewport height to
2624+ * the text field (vs the inline 6-line cap) with a Send button and a collapse action. */
2625+@OptIn(ExperimentalMaterial3Api::class)
2626+@Composable
2627+private fun FullScreenEditor(
2628+ value: String,
2629+ onValueChange: (String) -> Unit,
2630+ onDismiss: () -> Unit,
2631+ onSend: () -> Unit,
2632+ canSend: Boolean,
2633+ running: Boolean = false,
2634+ sendOnEnter: Boolean = false,
2635+ attachments: List<PendingAttachment> = emptyList(),
2636+ onRemoveAttachment: (String) -> Unit = {},
2637+ staging: Boolean = false,
2638+ stagingFileCount: Int = 0,
2639+) {
2640+ androidx.compose.ui.window.Dialog(
2641+ onDismissRequest = onDismiss,
2642+ properties = androidx.compose.ui.window.DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
2643+ ) {
2644+ // Cap the editor width on large screens (tablets) so the text field doesn't stretch
2645+ // edge-to-edge — matching the chat list's readability rationale. BoxWithConstraints
2646+ // gives the available width so we can center a capped-width column.
2647+ androidx.compose.foundation.layout.BoxWithConstraints(
2648+ modifier = Modifier.fillMaxSize(),
2649+ contentAlignment = Alignment.Center,
2650+ ) {
2651+ val hPad = if (maxWidth > NetworkConfig.twoPaneWidthThresholdDp.dp)
2652+ ((maxWidth - NetworkConfig.composerDialogMaxWidthDp.dp) / 2).coerceAtLeast(0.dp)
2653+ else 0.dp
2654+ Scaffold(
2655+ modifier = Modifier.padding(horizontal = hPad),
2656+ topBar = {
2657+ androidx.compose.material3.TopAppBar(
2658+ title = { Text(stringResource(R.string.compose_editor_title)) },
2659+ navigationIcon = {
2660+ androidx.compose.material3.TextButton(onClick = onDismiss) {
2661+ Text(stringResource(R.string.collapse_editor))
2662+ }
2663+ },
2664+ )
2665+ },
2666+ bottomBar = {
2667+ Surface(tonalElevation = 3.dp) {
2668+ Row(
2669+ modifier = Modifier.fillMaxWidth().imePadding().padding(12.dp),
2670+ horizontalArrangement = Arrangement.End,
2671+ ) {
2672+ androidx.compose.material3.Button(onClick = onSend, enabled = canSend) {
2673+ Text(stringResource(if (running) R.string.queue else R.string.send))
2674+ }
2675+ }
2676+ }
2677+ },
2678+ ) { padding ->
2679+ Column(modifier = Modifier.fillMaxSize().padding(padding)) {
2680+ // Show staged attachments in the full-screen editor too, so a user who staged
2681+ // images then expanded for a long prompt can still see and remove them without
2682+ // collapsing first (mirrors the inline composer's AttachmentStrip).
2683+ AttachmentStrip(attachments, onRemove = onRemoveAttachment, staging = staging, stagingFileCount = stagingFileCount)
2684+ OutlinedTextField(
2685+ value = value,
2686+ onValueChange = { v -> onValueChange(v.take(NetworkConfig.maxDraftLengthChars)) },
2687+ modifier = Modifier
2688+ .fillMaxSize()
2689+ .weight(1f)
2690+ // Honor the "Send on Enter" setting here too, so the full-screen
2691+ // editor matches the inline composer: Enter sends (Shift+Enter
2692+ // newlines) when on; Enter inserts a newline and Ctrl+Enter sends
2693+ // when off. Previously this hard-coded ImeAction.Default and had no
2694+ // key handler, so a hardware-keyboard user got a different behaviour
2695+ // from the inline composer depending on which they happened to open.
2696+ .onPreviewKeyEvent { event ->
2697+ if (event.type != KeyEventType.KeyDown || event.key != Key.Enter) return@onPreviewKeyEvent false
2698+ val send = enterShouldSend(
2699+ enabled = canSend,
2700+ hasContent = canSend,
2701+ sendOnEnter = sendOnEnter,
2702+ shift = event.isShiftPressed,
2703+ ctrl = event.isCtrlPressed,
2704+ )
2705+ if (send) { onSend(); true } else false
2706+ },
2707+ placeholder = { Text(stringResource(R.string.message_placeholder)) },
2708+ maxLines = Int.MAX_VALUE,
2709+ keyboardOptions = KeyboardOptions(
2710+ imeAction = if (sendOnEnter) ImeAction.Send else ImeAction.Default,
2711+ capitalization = KeyboardCapitalization.Sentences,
2712+ ),
2713+ keyboardActions = KeyboardActions(
2714+ onSend = { if (canSend) onSend() },
2715+ ),
2716+ )
2717+ }
2718+ }
2719+ }
2720+ }
2721+}
2722+
2723+/** Whether a hardware Enter keypress should send: never when disabled/empty; otherwise
2724+ * Enter sends (unless Shift) when send-on-Enter is on, else only Ctrl+Enter sends. */
2725+private fun enterShouldSend(
2726+ enabled: Boolean,
2727+ hasContent: Boolean,
2728+ sendOnEnter: Boolean,
2729+ shift: Boolean,
2730+ ctrl: Boolean,
2731+): Boolean = when {
2732+ !enabled || !hasContent -> false
2733+ sendOnEnter -> !shift
2734+ else -> ctrl
2735+}
2736+
2737+/** The composer's trailing button: while a run is active a Stop (with in-flight spinner),
2738+ * preceded by a Queue button when the user has typed a follow-up; otherwise Send. Extracted so
2739+ * [ChatInputBar] stays under the complexity threshold. */
2740+@Composable
2741+private fun ComposerTrailingButton(
2742+ running: Boolean,
2743+ aborting: Boolean,
2744+ canSend: Boolean,
2745+ canQueue: Boolean,
2746+ onSend: () -> Unit,
2747+ onAbort: () -> Unit,
2748+ onQueue: () -> Unit,
2749+) {
2750+ if (running) {
2751+ Row(verticalAlignment = Alignment.CenterVertically) {
2752+ // While a run is active the trailing action is Stop, so the only send affordance is
2753+ // the keyboard's IME action — which is a plain newline when "Send on Enter" is off.
2754+ // That leaves a touch-only user with the setting off no way to queue a follow-up
2755+ // (Ctrl+Enter needs a hardware keyboard). Surface an explicit Queue button whenever
2756+ // there's typed TEXT so queuing is always reachable, without changing the user's
2757+ // Enter-inserts-a-newline preference. Gate on typed text — not the send-oriented
2758+ // canSend, which is also true for an attachment-only composer — because queuing "" just
2759+ // calls setQueuedFollowUp(null), silently cancelling any already-queued follow-up and
2760+ // dropping the staged attachment (queued follow-ups don't carry attachments anyway).
2761+ if (canQueue) {
2762+ IconButton(
2763+ onClick = onQueue,
2764+ modifier = Modifier.testTag("queue_button"),
2765+ ) {
2766+ // PlaylistAdd reads as "add to a queue/list" more clearly than a clock
2767+ // icon (which can be read as "snooze" or "later"), and isn't confused with
2768+ // the Send action sitting right beside the Stop button.
2769+ Icon(
2770+ Icons.AutoMirrored.Filled.PlaylistAdd,
2771+ contentDescription = stringResource(R.string.queue_followup),
2772+ )
2773+ }
2774+ }
2775+ // Show a spinner while the abort REST call is in flight so the user sees the stop
2776+ // was sent, and disable to prevent a double-tap.
2777+ IconButton(
2778+ onClick = onAbort,
2779+ enabled = !aborting,
2780+ modifier = Modifier.padding(start = 4.dp).testTag("stop_button"),
2781+ ) {
2782+ if (aborting) {
2783+ val stopLabel = stringResource(R.string.stop)
2784+ CircularProgressIndicator(
2785+ Modifier.size(18.dp).semantics { contentDescription = stopLabel },
2786+ strokeWidth = 2.dp,
2787+ )
2788+ } else {
2789+ Icon(Icons.Filled.Stop, contentDescription = stringResource(R.string.stop))
2790+ }
2791+ }
2792+ }
2793+ } else {
2794+ IconButton(
2795+ onClick = onSend,
2796+ enabled = canSend,
2797+ modifier = Modifier.padding(start = 4.dp).testTag("send_button"),
2798+ ) {
2799+ // Tint the send icon with primary when there's content to send so the commit action
2800+ // draws the eye; muted otherwise. A plain enabled/disabled flip is easy to miss.
2801+ Icon(
2802+ Icons.AutoMirrored.Filled.Send,
2803+ contentDescription = stringResource(R.string.send),
2804+ tint = if (canSend) MaterialTheme.colorScheme.primary
2805+ else MaterialTheme.colorScheme.onSurfaceVariant,
2806+ )
2807+ }
2808+ }
2809+}
2810+
2811+@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
2812+@Composable
2813+private fun EmptyConversation(
2814+ onSuggestion: (String) -> Unit,
2815+ modifier: Modifier = Modifier,
2816+) {
2817+ // A few example prompts so a first-time user has a one-tap path to their first
2818+ // message instead of staring at a blank input. Tapping a chip fills the draft
2819+ // (without sending) so the user can edit it before sending.
2820+ val suggestions = remember {
2821+ listOf(
2822+ R.string.empty_chat_suggest_1,
2823+ R.string.empty_chat_suggest_2,
2824+ R.string.empty_chat_suggest_3,
2825+ )
2826+ }
2827+ Column(
2828+ modifier = modifier.padding(32.dp),
2829+ horizontalAlignment = Alignment.CenterHorizontally,
2830+ ) {
2831+ Icon(
2832+ Icons.Filled.AutoAwesome,
2833+ contentDescription = null,
2834+ modifier = Modifier.size(48.dp),
2835+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
2836+ )
2837+ Spacer(Modifier.size(16.dp))
2838+ Text(
2839+ stringResource(R.string.empty_chat_title),
2840+ style = MaterialTheme.typography.titleMedium,
2841+ )
2842+ Spacer(Modifier.size(8.dp))
2843+ Text(
2844+ stringResource(R.string.empty_chat_hint),
2845+ style = MaterialTheme.typography.bodyMedium,
2846+ color = MaterialTheme.colorScheme.onSurfaceVariant,
2847+ )
2848+ Spacer(Modifier.size(20.dp))
2849+ // Suggestion chips wrap across rows on narrow screens instead of stacking full-width,
2850+ // so a longer localized prompt doesn't push the chips off-screen on a phone.
2851+ FlowRow(
2852+ modifier = Modifier.fillMaxWidth(),
2853+ horizontalArrangement = Arrangement.spacedBy(8.dp),
2854+ verticalArrangement = Arrangement.spacedBy(8.dp),
2855+ ) {
2856+ suggestions.forEach { resId ->
2857+ val text = stringResource(resId)
2858+ androidx.compose.material3.AssistChip(
2859+ onClick = { onSuggestion(text) },
2860+ label = { Text(text, style = MaterialTheme.typography.bodyMedium) },
2861+ )
2862+ }
2863+ }
2864+ }
2865+}
2866+
2867+/** A banner shown while a revert checkpoint is active, offering an Undo (unrevert). When the
2868+ * server included a diff of the revert, a collapsible "Show what changed" affordance reveals
2869+ * it inline via [DiffView] so the user can see what was rolled back before deciding to undo. */
2870+@Composable
2871+private fun RevertBanner(diff: String?, isEditing: Boolean, onUndo: () -> Unit) {
2872+ val haptics = LocalHapticFeedback.current
2873+ var showDiff by rememberSaveable { mutableStateOf(false) }
2874+ val expandMotion = rememberVisibilityTransitions()
2875+ Surface(
2876+ color = MaterialTheme.colorScheme.tertiaryContainer,
2877+ tonalElevation = 2.dp,
2878+ modifier = Modifier.fillMaxWidth(),
2879+ ) {
2880+ Column {
2881+ Row(
2882+ modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
2883+ verticalAlignment = Alignment.CenterVertically,
2884+ horizontalArrangement = Arrangement.SpaceBetween,
2885+ ) {
2886+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
2887+ Icon(
2888+ if (isEditing) Icons.Filled.Edit else Icons.Filled.Restore,
2889+ contentDescription = null,
2890+ tint = MaterialTheme.colorScheme.onTertiaryContainer,
2891+ modifier = Modifier.size(18.dp),
2892+ )
2893+ Text(
2894+ stringResource(if (isEditing) R.string.editing_banner else R.string.reverted_banner),
2895+ style = MaterialTheme.typography.bodySmall,
2896+ color = MaterialTheme.colorScheme.onTertiaryContainer,
2897+ modifier = Modifier.padding(start = 8.dp),
2898+ )
2899+ // "Show what changed" toggle, shown only when the server provided a diff.
2900+ // Collapsible so the banner stays compact by default; a user curious about
2901+ // what the revert rolled back can expand it inline without leaving the chat.
2902+ if (!diff.isNullOrBlank()) {
2903+ TextButton(
2904+ onClick = {
2905+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
2906+ showDiff = !showDiff
2907+ },
2908+ contentPadding = androidx.compose.foundation.layout.PaddingValues(start = 8.dp, end = 4.dp),
2909+ ) {
2910+ Icon(
2911+ if (showDiff) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
2912+ contentDescription = null,
2913+ modifier = Modifier.size(16.dp),
2914+ )
2915+ Text(
2916+ stringResource(if (showDiff) R.string.revert_hide_diff else R.string.revert_show_diff),
2917+ style = MaterialTheme.typography.labelSmall,
2918+ modifier = Modifier.padding(start = 4.dp),
2919+ )
2920+ }
2921+ }
2922+ }
2923+ TextButton(onClick = {
2924+ // Haptic on undo to match the other destructive/important confirmations: a
2925+ // revert restores potentially hundreds of hidden messages.
2926+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
2927+ onUndo()
2928+ }) { Text(stringResource(R.string.undo)) }
2929+ }
2930+ // The diff render lives inside the banner's Surface so it inherits the tertiary
2931+ // tonal container and reads as part of the banner, not a floating panel.
2932+ if (!diff.isNullOrBlank()) {
2933+ AnimatedVisibility(
2934+ visible = showDiff,
2935+ enter = expandMotion.enter,
2936+ exit = expandMotion.exit,
2937+ ) {
2938+ Column(modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 8.dp)) {
2939+ Text(
2940+ stringResource(R.string.revert_diff_header),
2941+ style = MaterialTheme.typography.labelSmall,
2942+ color = MaterialTheme.colorScheme.onTertiaryContainer,
2943+ modifier = Modifier.padding(bottom = 4.dp),
2944+ )
2945+ DiffView(diff, saveKey = "revertDiff")
2946+ }
2947+ }
2948+ }
2949+ }
2950+ }
2951+}
2952+
2953+/** A dismissible preview card shown above the composer when the user quote-replied to a
2954+ * message. Displays the quoted text (truncated) with a Cancel affordance so the user can
2955+ * abort the quote without digging into the draft to strip the `>`-prefixed lines. Mirrors
2956+ * the Discord/Slack quote-preview pattern. */
2957+@Composable
2958+private fun QuoteReplyBanner(quotedText: String, onCancel: () -> Unit) {
2959+ val haptics = LocalHapticFeedback.current
2960+ Surface(
2961+ color = MaterialTheme.colorScheme.secondaryContainer,
2962+ tonalElevation = 2.dp,
2963+ modifier = Modifier.fillMaxWidth(),
2964+ ) {
2965+ Row(
2966+ modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 4.dp, top = 6.dp, bottom = 6.dp),
2967+ verticalAlignment = Alignment.Top,
2968+ ) {
2969+ Icon(
2970+ Icons.Filled.FormatQuote,
2971+ contentDescription = null,
2972+ tint = MaterialTheme.colorScheme.onSecondaryContainer,
2973+ modifier = Modifier.size(18.dp).padding(top = 2.dp),
2974+ )
2975+ Column(modifier = Modifier.weight(1f).padding(start = 8.dp)) {
2976+ Text(
2977+ stringResource(R.string.quote_reply_banner),
2978+ style = MaterialTheme.typography.labelSmall,
2979+ color = MaterialTheme.colorScheme.onSecondaryContainer,
2980+ )
2981+ Text(
2982+ quotedText,
2983+ style = MaterialTheme.typography.bodySmall,
2984+ color = MaterialTheme.colorScheme.onSecondaryContainer,
2985+ maxLines = 2,
2986+ overflow = TextOverflow.Ellipsis,
2987+ )
2988+ }
2989+ IconButton(onClick = {
2990+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
2991+ onCancel()
2992+ }) {
2993+ Icon(
2994+ Icons.Filled.Close,
2995+ contentDescription = stringResource(R.string.cancel_quote_reply),
2996+ tint = MaterialTheme.colorScheme.onSecondaryContainer,
2997+ modifier = Modifier.size(18.dp),
2998+ )
2999+ }
3000+ }
3001+ }
3002+}
3003+
3004+/** A banner shown while messages composed offline are queued in the outbox, with a
3005+ * "Send now" nudge and a discard. Shows a spinner while the queue is flushing. */
3006+@Composable
3007+private fun OutboxBanner(
3008+ count: Int,
3009+ sending: Boolean,
3010+ onFlush: () -> Unit,
3011+ onDiscard: () -> Unit,
3012+) {
3013+ var showDiscardConfirm by rememberSaveable { mutableStateOf(false) }
3014+ Surface(
3015+ color = MaterialTheme.colorScheme.secondaryContainer,
3016+ tonalElevation = 2.dp,
3017+ modifier = Modifier.fillMaxWidth(),
3018+ ) {
3019+ Row(
3020+ modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
3021+ verticalAlignment = Alignment.CenterVertically,
3022+ horizontalArrangement = Arrangement.SpaceBetween,
3023+ ) {
3024+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
3025+ if (sending) {
3026+ CircularProgressIndicator(
3027+ Modifier.size(16.dp),
3028+ strokeWidth = 2.dp,
3029+ color = MaterialTheme.colorScheme.onSecondaryContainer,
3030+ )
3031+ Spacer(Modifier.size(8.dp))
3032+ }
3033+ Text(
3034+ if (sending) {
3035+ stringResource(R.string.outbox_sending)
3036+ } else {
3037+ val res = if (count == 1) R.string.outbox_pending_one else R.string.outbox_pending_many
3038+ stringResource(res, count)
3039+ },
3040+ style = MaterialTheme.typography.bodySmall,
3041+ color = MaterialTheme.colorScheme.onSecondaryContainer,
3042+ )
3043+ }
3044+ if (!sending) {
3045+ val haptics = LocalHapticFeedback.current
3046+ TextButton(onClick = onFlush) { Text(stringResource(R.string.outbox_flush)) }
3047+ TextButton(onClick = {
3048+ // Confirm before discarding all queued messages (irreversible), matching
3049+ // the confirmation pattern used by deleteSession and other destructive
3050+ // actions. Previously a single tap dropped every queued message.
3051+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
3052+ showDiscardConfirm = true
3053+ }) {
3054+ Text(stringResource(R.string.outbox_discard_all), color = MaterialTheme.colorScheme.error)
3055+ }
3056+ }
3057+ }
3058+ }
3059+ if (showDiscardConfirm) {
3060+ androidx.compose.material3.AlertDialog(
3061+ onDismissRequest = { showDiscardConfirm = false },
3062+ title = { Text(stringResource(R.string.discard_queued_title)) },
3063+ text = { Text(stringResource(R.string.discard_queued_text, count)) },
3064+ confirmButton = {
3065+ androidx.compose.material3.TextButton(onClick = {
3066+ showDiscardConfirm = false
3067+ onDiscard()
3068+ }) { Text(stringResource(R.string.discard)) }
3069+ },
3070+ dismissButton = {
3071+ androidx.compose.material3.TextButton(onClick = { showDiscardConfirm = false }) {
3072+ Text(stringResource(R.string.cancel))
3073+ }
3074+ },
3075+ )
3076+ }
3077+}
3078+
3079+/** Prompt for a one-off shell command to run in the session's worktree. */
3080+@Composable
3081+private fun ShellCommandDialog(onDismiss: () -> Unit, onRun: (String) -> Unit) {
3082+ var command by rememberSaveable { mutableStateOf("") }
3083+ AlertDialog(
3084+ onDismissRequest = onDismiss,
3085+ title = { Text(stringResource(R.string.run_shell_command)) },
3086+ text = {
3087+ OutlinedTextField(
3088+ value = command,
3089+ onValueChange = { command = it },
3090+ modifier = Modifier.fillMaxWidth(),
3091+ placeholder = { Text(stringResource(R.string.shell_command_hint)) },
3092+ label = { Text(stringResource(R.string.shell_command_label)) },
3093+ singleLine = true,
3094+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
3095+ keyboardActions = KeyboardActions(onDone = { if (command.isNotBlank()) onRun(command.trim()) }),
3096+ )
3097+ },
3098+ confirmButton = {
3099+ TextButton(
3100+ onClick = { if (command.isNotBlank()) onRun(command.trim()) },
3101+ enabled = command.isNotBlank(),
3102+ ) { Text(stringResource(R.string.run)) }
3103+ },
3104+ dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },
3105+ )
3106+}
3107+
3108+/**
3109+ * Change signal for the auto-scroll-on-stream watcher. Primitive fields avoid the
3110+ * boxing `Triple<Int, Int, Boolean>` would impose on every snapshot evaluation, and
3111+ * equality is structural so `snapshotFlow` emits only when something relevant changes.
3112+ */
3113+private data class AutoScrollSignal(val size: Int, val lastTextLength: Int, val pinned: Boolean)
3114+
3115+/** Length of the streaming content carried by [part], driving auto-scroll as it grows.
3116+ * Covers text, reasoning, and tool output so a pinned view keeps following non-text
3117+ * streaming, not just plain assistant text. O(1). */
3118+private fun streamingContentLength(part: Part?): Int = when (part) {
3119+ is TextPart -> part.text.length
3120+ is ReasoningPart -> part.text.length
3121+ is ToolPart -> when (val st = part.state) {
3122+ is ToolCompleted -> st.output?.length ?: 0
3123+ is ToolError -> st.error?.length ?: 0
3124+ is ToolRunning -> st.title?.length ?: 0
3125+ else -> 0
3126+ }
3127+ else -> 0
3128+}
3129+
3130+/** Rename dialog for the currently-open session. Mirrors the SessionListScreen rename
3131+ * dialog but lives in the chat screen so the user can rename without backing out to
3132+ * the list. Pre-filled with the current title; the cap matches the list dialog. */
3133+@Composable
3134+private fun RenameSessionChatDialog(
3135+ initialTitle: String,
3136+ onDismiss: () -> Unit,
3137+ onConfirm: (String) -> Unit,
3138+) {
3139+ // Seed the field once (no key input) so a later external title change — e.g. opencode
3140+ // auto-generating a session title after the first prompt, arriving via SessionUpdated —
3141+ // doesn't re-initialize the box and discard the user's in-progress edit.
3142+ var title by rememberSaveable { mutableStateOf(initialTitle) }
3143+ AlertDialog(
3144+ onDismissRequest = onDismiss,
3145+ title = { Text(stringResource(R.string.rename_session_chat)) },
3146+ text = {
3147+ OutlinedTextField(
3148+ value = title,
3149+ onValueChange = { v -> title = v.take(NetworkConfig.maxSessionTitleChars) },
3150+ modifier = Modifier.fillMaxWidth(),
3151+ placeholder = { Text(stringResource(R.string.session_title_hint)) },
3152+ label = { Text(stringResource(R.string.rename_session_chat)) },
3153+ singleLine = true,
3154+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
3155+ keyboardActions = KeyboardActions(onDone = { if (title.isNotBlank()) onConfirm(title.trim()) }),
3156+ )
3157+ },
3158+ confirmButton = {
3159+ TextButton(onClick = { if (title.isNotBlank()) onConfirm(title.trim()) }, enabled = title.isNotBlank()) {
3160+ Text(stringResource(R.string.save))
3161+ }
3162+ },
3163+ dismissButton = {
3164+ TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
3165+ },
3166+ )
3167+}
3168+
3169+/**
3170+ * A single item in the chat message list — either a [Message] bubble or a day
3171+ * [Separator] inserted before the first message of a new day. The separator shifts
3172+ * the LazyColumn indices vs. the raw messages list, so scroll-target math must use
3173+ * the [List] of these items (not `messages.size`/`lastIndex`).
3174+ */
3175+private sealed interface MessageListItem {
3176+ val key: Any
3177+ val contentType: Any
3178+
3179+ data class Message(
3180+ val message: MessageWithParts,
3181+ override val key: Any,
3182+ // Whether this is the first message in a consecutive run of the same speaker (role).
3183+ // Used to suppress the assistant avatar/header on follow-up messages in the same run,
3184+ // reducing visual noise in tool-heavy conversations where many assistant messages
3185+ // appear back-to-back. A separator or a role change resets the flag.
3186+ val isFirstOfSpeaker: Boolean,
3187+ ) : MessageListItem {
3188+ override val contentType: Any get() = message.info::class
3189+ }
3190+
3191+ data class Separator(val label: String, val ordinal: Int) : MessageListItem {
3192+ // Key on the day-occurrence ordinal, not the label: two non-contiguous groups
3193+ // can share a label (e.g. an untimestamped message — empty label — between two
3194+ // same-day messages, or two untimestamped groups), and a label-only key would
3195+ // then collide, which makes LazyColumn throw on duplicate keys. The ordinal is
3196+ // stable for a given message ordering so slots aren't needlessly recreated.
3197+ override val key: Any get() = "sep_${ordinal}_$label"
3198+ // Separators share a contentType so the LazyColumn can recycle their slots.
3199+ override val contentType: Any get() = "separator"
3200+ }
3201+}
3202+
3203+/**
3204+ * Build the interleaved list of [MessageListItem]s for the message list, inserting a
3205+ * [MessageListItem.Separator] before the first message of each new calendar day. Uses
3206+ * the message's `time.created` (falling back to `updated` then `completed`) to bucket.
3207+ * Messages with no timestamp are grouped under an empty-label separator only if they
3208+ * start the list, so a server that omits timestamps doesn't suppress the first divider.
3209+ *
3210+ * [todayLabel]/[yesterdayLabel] are resolved by the caller (a @Composable can't call
3211+ * stringResource from inside this plain function) and used for the "Today"/"Yesterday"
3212+ * labels; older days fall back to a locale-stable medium-date format.
3213+ */
3214+private fun buildMessageListItems(
3215+ messages: List<MessageWithParts>,
3216+ todayLabel: String,
3217+ yesterdayLabel: String,
3218+): List<MessageListItem> {
3219+ if (messages.isEmpty()) return emptyList()
3220+ val today = java.time.LocalDate.now(java.time.ZoneId.systemDefault())
3221+ val dateFmt = mediumDateFormat()
3222+ val result = ArrayList<MessageListItem>(messages.size + 4)
3223+ val seenKeys = HashSet<String>(messages.size * 2)
3224+ var lastDayKey: String? = null
3225+ var sepOrdinal = 0
3226+ var emptyIdOrdinal = 0
3227+ var first = true
3228+ // Track the previous message's role to detect consecutive same-speaker runs, so the
3229+ // avatar/header can be suppressed on follow-up messages in the same run. A null means
3230+ // "no previous message or a separator just interrupted the run", both of which make the
3231+ // next message the first of its group.
3232+ var prevRoleClass: kotlin.reflect.KClass<out soy.iko.opencode.data.model.MessageInfo>? = null
3233+ for (message in messages) {
3234+ val ts = message.info.time?.created ?: message.info.time?.updated ?: message.info.time?.completed
3235+ val dayKey = ts?.let { dayKey(it) } ?: ""
3236+ // Emit a separator before the very first message (so a leading un-timestamped message
3237+ // still gets the first divider, empty label and all), and thereafter only when crossing
3238+ // into a new NON-empty calendar day. An un-timestamped message mid-list (empty key)
3239+ // neither emits its own separator nor resets the day — otherwise a following same-day
3240+ // message would see a "" -> "<today>" transition and emit a duplicate "Today" header.
3241+ if (first || (dayKey.isNotEmpty() && dayKey != lastDayKey)) {
3242+ result.add(MessageListItem.Separator(dayLabel(dayKey, ts ?: 0L, today, todayLabel, yesterdayLabel, dateFmt), sepOrdinal++))
3243+ // A date separator visually breaks a speaker run, so the next message is treated
3244+ // as the first of its group (even if the same role continues across the day break).
3245+ prevRoleClass = null
3246+ }
3247+ if (dayKey.isNotEmpty()) lastDayKey = dayKey
3248+ first = false
3249+ // Most messages have a unique non-empty id. An unrecognized-role message can carry an
3250+ // empty id — and the reducer intentionally keeps several such messages as distinct
3251+ // holders — so give each a stable synthetic key and defensively de-collide any remaining
3252+ // duplicate; otherwise LazyColumn throws on a duplicate key and the whole screen crashes.
3253+ var key = message.info.id.ifEmpty { "msg-empty-${emptyIdOrdinal++}" }
3254+ if (!seenKeys.add(key)) {
3255+ var suffix = 1
3256+ while (!seenKeys.add("$key#$suffix")) suffix++
3257+ key = "$key#$suffix"
3258+ }
3259+ val roleClass = message.info::class
3260+ val isFirstOfSpeaker = prevRoleClass == null || roleClass != prevRoleClass
3261+ result.add(MessageListItem.Message(message, key, isFirstOfSpeaker))
3262+ prevRoleClass = roleClass
3263+ }
3264+ return result
3265+}
3266+
3267+/**
3268+ * A stable bucket key for a timestamp's calendar day (epoch-days as a string).
3269+ *
3270+ * [buildMessageListItems] re-runs on every streaming snapshot (the memo is keyed on the
3271+ * whole `messages` list, which must stay so the live message's fresh parts render), so a
3272+ * naive implementation would re-allocate an `Instant`/`ZonedDateTime`/`LocalDate` for
3273+ * every message on every token. Message timestamps are immutable once assigned, so we
3274+ * memoize the parse per epoch-millis: after the first snapshot, each unchanged message is
3275+ * a cheap hash lookup. Bounded; only touched on the main thread during composition.
3276+ *
3277+ * Backed by a [ConcurrentHashMap] (not an access-ordered LinkedHashMap): the previous
3278+ * synchronized access-ordered LinkedHashMap reordered its linked list node on every
3279+ * `getOrPut` hit, which is real work multiplied by N messages × ~20 snapshots/sec during
3280+ * streaming. A ConcurrentHashMap has no access-order bookkeeping and is lock-free for
3281+ * reads, so the common case (a hit) is a single volatile read + hash probe.
3282+ * [removeEldestEntry] is not available on ConcurrentHashMap, so the bound is enforced via
3283+ * a size check in [dayKey]: once the cap ([DAY_KEY_CACHE_MAX]) is reached the cache is
3284+ * cleared; in practice the cache size is bounded by the number of distinct message
3285+ * timestamps in a session (≤ [NetworkConfig.maxInMemoryMessages]) and the cap is a
3286+ * safety valve for the process-lifetime cache across many sessions.
3287+ */
3288+private val dayKeyCache = java.util.concurrent.ConcurrentHashMap<Long, String>(256)
3289+
3290+/** Upper bound on [dayKeyCache] entries. Message timestamps are immutable once assigned, so
3291+ * the natural bound is the number of distinct timestamps in a session (≤
3292+ * [NetworkConfig.maxInMemoryMessages]); this cap is a safety valve for the process-lifetime
3293+ * cache across many sessions, keeping a runaway leak from accumulating unbounded memory. */
3294+private const val DAY_KEY_CACHE_MAX = 1024
3295+
3296+private fun dayKey(epochMillis: Long): String {
3297+ val cached = dayKeyCache[epochMillis]
3298+ if (cached != null) return cached
3299+ val value = java.time.Instant.ofEpochMilli(epochMillis)
3300+ .atZone(java.time.ZoneId.systemDefault()).toLocalDate().toEpochDay().toString()
3301+ // Guard the size under the cache's concurrency: only the first thread to see the cap
3302+ // exceeded evicts (draining is O(N) but rare and idempotent), so concurrent inserts
3303+ // past the cap don't each pay the scan. computeIfAbsent would hold the segment lock
3304+ // during the (cheap) date computation but can't express the size guard, so use put
3305+ // after the fact; a duplicate put of the same key/value is harmless.
3306+ if (dayKeyCache.size >= DAY_KEY_CACHE_MAX) dayKeyCache.clear()
3307+ dayKeyCache[epochMillis] = value
3308+ return value
3309+}
3310+
3311+// The medium-date formatter is an ICU locale lookup; cache one instance instead of
3312+// allocating a fresh one per buildMessageListItems call, rebuilding only if the default
3313+// locale changes at runtime. Main-thread-only, so no synchronization is needed.
3314+private var cachedDateFmt: java.text.DateFormat? = null
3315+private var cachedDateFmtLocale: java.util.Locale? = null
3316+
3317+private fun mediumDateFormat(): java.text.DateFormat {
3318+ val locale = java.util.Locale.getDefault()
3319+ val existing = cachedDateFmt
3320+ if (existing != null && cachedDateFmtLocale == locale) return existing
3321+ return java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM).also {
3322+ cachedDateFmt = it
3323+ cachedDateFmtLocale = locale
3324+ }
3325+}
3326+
3327+/**
3328+ * Resolve a day-key back to a human-readable label. "Today" / "Yesterday" for the
3329+ * recent days, otherwise a locale-stable medium-date format. Returns an empty string
3330+ * when [dayKey] is empty (no timestamp) so no separator is rendered for un-timestamped
3331+ * messages.
3332+ */
3333+private fun dayLabel(
3334+ dayKey: String,
3335+ ts: Long,
3336+ today: java.time.LocalDate,
3337+ todayLabel: String,
3338+ yesterdayLabel: String,
3339+ dateFmt: java.text.DateFormat,
3340+): String {
3341+ if (dayKey.isEmpty()) return ""
3342+ val epochDay = dayKey.toLongOrNull() ?: return ""
3343+ val date = java.time.LocalDate.ofEpochDay(epochDay)
3344+ return when {
3345+ date == today -> todayLabel
3346+ date == today.minusDays(1) -> yesterdayLabel
3347+ else -> dateFmt.format(java.util.Date(ts))
3348+ }
3349+}
3350+
3351+/**
3352+ * A centered day divider in the message list. Renders nothing for an empty label
3353+ * (the no-timestamp case) so un-timestamped messages don't get a stray blank divider.
3354+ */
3355+@Composable
3356+private fun DateSeparator(label: String) {
3357+ if (label.isBlank()) return
3358+ Row(
3359+ modifier = Modifier.fillMaxWidth(),
3360+ horizontalArrangement = Arrangement.Center,
3361+ ) {
3362+ Surface(
3363+ color = MaterialTheme.colorScheme.surfaceVariant,
3364+ shape = MaterialTheme.shapes.small,
3365+ // Marked as a heading so TalkBack users can navigate day-by-day with the
3366+ // heading-skip gesture — the canonical affordance for date dividers in a list.
3367+ modifier = Modifier.semantics(mergeDescendants = true) { heading() },
3368+ ) {
3369+ Text(
3370+ label,
3371+ style = MaterialTheme.typography.labelSmall,
3372+ color = MaterialTheme.colorScheme.onSurfaceVariant,
3373+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
3374+ )
3375+ }
3376+ }
3377+}
3378+
3379+/** Skeleton placeholder bubbles for the chat loading and run-started states. Alpha pulses
3380+ * (gated by reduced-motion) to convey ongoing work; the column carries the a11y label so
3381+ * TalkBack announces "Loading" instead of reading the skeleton bars. Rendered as a
3382+ * [BoxScope] extension so it can align itself to the top center of its host box. */
3383+@Composable
3384+private fun androidx.compose.foundation.layout.BoxScope.ChatSkeleton(topPad: androidx.compose.ui.unit.Dp) {
3385+ val loadingLabel = stringResource(R.string.loading)
3386+ val reducedMotion = LocalReducedMotion.current
3387+ val transition = rememberInfiniteTransition(label = "skeleton")
3388+ val pulse by transition.animateFloat(
3389+ initialValue = 0.35f,
3390+ targetValue = 0.7f,
3391+ animationSpec = infiniteRepeatable(tween(900), RepeatMode.Reverse),
3392+ label = "skeletonAlpha",
3393+ )
3394+ val skeletonAlpha = if (reducedMotion) 0.5f else pulse
3395+ val skeletonColor = MaterialTheme.colorScheme.surfaceVariant
3396+ @Composable
3397+ fun bar(widthFraction: Float) {
3398+ Box(
3399+ Modifier
3400+ .fillMaxWidth(widthFraction)
3401+ .height(14.dp)
3402+ .clip(RoundedCornerShape(4.dp))
3403+ .background(skeletonColor)
3404+ .alpha(skeletonAlpha),
3405+ )
3406+ }
3407+ Column(
3408+ modifier = Modifier
3409+ .align(Alignment.TopCenter)
3410+ .padding(top = topPad)
3411+ .padding(16.dp)
3412+ .fillMaxWidth()
3413+ .semantics { contentDescription = loadingLabel },
3414+ verticalArrangement = Arrangement.spacedBy(14.dp),
3415+ ) {
3416+ repeat(4) { i ->
3417+ val left = i % 2 == 0
3418+ Row(
3419+ modifier = Modifier.fillMaxWidth(),
3420+ horizontalArrangement = if (left) Arrangement.Start else Arrangement.End,
3421+ ) {
3422+ Column(
3423+ modifier = Modifier.fillMaxWidth(if (left) 0.7f else 0.55f),
3424+ verticalArrangement = Arrangement.spacedBy(6.dp),
3425+ ) {
3426+ bar(1f)
3427+ bar(0.8f)
3428+ }
3429+ }
3430+ }
3431+ }
3432+}
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.ktnew file+2025
@@ -0,0 +1,2025 @@
1+package soy.iko.opencode.ui.chat
2+
3+import androidx.lifecycle.ViewModel
4+import androidx.lifecycle.viewModelScope
5+import soy.iko.opencode.data.model.Agent
6+import soy.iko.opencode.data.model.Command
7+import soy.iko.opencode.data.model.FilePromptPart
8+import soy.iko.opencode.data.model.MessageWithParts
9+import soy.iko.opencode.data.model.ModelOption
10+import soy.iko.opencode.data.model.UserMessage
11+import soy.iko.opencode.data.model.Permission
12+import soy.iko.opencode.data.model.PermissionReplied
13+import soy.iko.opencode.data.model.PermissionResponse
14+import soy.iko.opencode.data.model.PermissionUpdated
15+import soy.iko.opencode.data.model.SessionDeleted
16+import soy.iko.opencode.data.model.SessionUpdated
17+import soy.iko.opencode.data.model.TextPart
18+import soy.iko.opencode.data.model.TodoItem
19+import soy.iko.opencode.data.model.TodoPlanCache
20+import soy.iko.opencode.data.model.currentTodoPlan
21+import soy.iko.opencode.data.model.defaultOption
22+import soy.iko.opencode.data.model.toOptions
23+import soy.iko.opencode.data.network.EventStreamClient
24+import soy.iko.opencode.data.network.NetworkConfig
25+import soy.iko.opencode.data.repo.OutboxMessage
26+import soy.iko.opencode.data.repo.SessionRepository
27+import soy.iko.opencode.di.AppContainer
28+import soy.iko.opencode.R
29+import soy.iko.opencode.util.runCatchingCancellable
30+import soy.iko.opencode.util.safeExceptionSummary
31+import android.util.Log
32+import kotlinx.coroutines.ExperimentalCoroutinesApi
33+import kotlinx.coroutines.channels.BufferOverflow
34+import kotlinx.coroutines.channels.Channel
35+import kotlinx.coroutines.flow.Flow
36+import kotlinx.coroutines.flow.MutableSharedFlow
37+import kotlinx.coroutines.flow.MutableStateFlow
38+import kotlinx.coroutines.flow.SharedFlow
39+import kotlinx.coroutines.flow.SharingStarted
40+import kotlinx.coroutines.flow.StateFlow
41+import kotlinx.coroutines.flow.asSharedFlow
42+import kotlinx.coroutines.flow.asStateFlow
43+import kotlinx.coroutines.flow.receiveAsFlow
44+import kotlinx.coroutines.CancellationException
45+import kotlinx.coroutines.flow.collectLatest
46+import kotlinx.coroutines.flow.combine
47+import kotlinx.coroutines.flow.debounce
48+import kotlinx.coroutines.flow.distinctUntilChanged
49+import kotlinx.coroutines.flow.drop
50+import kotlinx.coroutines.flow.first
51+import kotlinx.coroutines.flow.filterIsInstance
52+import kotlinx.coroutines.flow.flatMapLatest
53+import kotlinx.coroutines.flow.flowOf
54+import kotlinx.coroutines.flow.flowOn
55+import kotlinx.coroutines.flow.map
56+import kotlinx.coroutines.flow.merge
57+import kotlinx.coroutines.flow.onEach
58+import kotlinx.coroutines.flow.retryWhen
59+import kotlinx.coroutines.flow.stateIn
60+import kotlinx.coroutines.flow.update
61+import kotlinx.coroutines.Dispatchers
62+import kotlinx.coroutines.Job
63+import kotlinx.coroutines.NonCancellable
64+import kotlinx.coroutines.delay
65+import kotlinx.coroutines.isActive
66+import kotlinx.coroutines.currentCoroutineContext
67+import kotlinx.coroutines.launch
68+import kotlinx.coroutines.withContext
69+
70+/**
71+ * A user-facing error to surface as a snackbar. [retryable] is true only for a failed
72+ * message *send* (whose draft is held in [ChatViewModel.failedDraft]); the snackbar attaches
73+ * a Retry action solely for those so an unrelated error (e.g. a message-load failure) can't
74+ * inherit a Retry that silently re-submits the last prompt.
75+ */
76+data class ChatError(val message: String, val retryable: Boolean = false)
77+
78+/**
79+ * Progress of the current permission within a burst of requests. [total] is the max number
80+ * simultaneously pending since the queue was last empty; [position] advances as the user clears
81+ * the backlog. A [total] of 0/1 means there's no meaningful backlog to surface.
82+ */
83+@androidx.compose.runtime.Immutable
84+data class PermissionProgress(val position: Int, val total: Int)
85+
86+/**
87+ * An attachment staged to send with the next prompt. [previewModel] is a Coil-loadable model
88+ * for the thumbnail (the source content Uri, as a string) for images, or null for non-image
89+ * files (rendered with a generic icon). [part] is the wire form (a base64 data URL) sent to
90+ * the server.
91+ */
92+@androidx.compose.runtime.Immutable
93+data class PendingAttachment(
94+ val id: String,
95+ val name: String,
96+ val mime: String,
97+ val previewModel: Any?,
98+ val part: FilePromptPart,
99+) {
100+ val isImage: Boolean get() = mime.startsWith("image/")
101+}
102+
103+/** Reduce a staged attachment to its persistable form (the self-contained data URL). */
104+private fun PendingAttachment.toPersisted() =
105+ soy.iko.opencode.data.repo.PersistedAttachment(id, name, mime, part.url, part.filename)
106+
107+/** Rebuild a staged attachment from persistence. The base64 data URL doubles as the Coil
108+ * preview model for images (the original content Uri didn't survive the process restart). */
109+private fun soy.iko.opencode.data.repo.PersistedAttachment.toPending() = PendingAttachment(
110+ id = id,
111+ name = name,
112+ mime = mime,
113+ previewModel = if (mime.startsWith("image/")) url else null,
114+ part = FilePromptPart(mime = mime, url = url, filename = filename),
115+)
116+
117+// Large by design: this VM owns the whole chat surface (streaming, drafts, attachments,
118+// catalogs, permissions, revert/edit, sharing, summarize/init/shell, TTS wiring). It's
119+// already grandfathered for TooManyFunctions in the detekt baseline; the class-size rule is
120+// suppressed for the same reason. A future split into sub-controllers is tracked separately.
121+@Suppress("LargeClass")
122+@OptIn(ExperimentalCoroutinesApi::class, kotlinx.coroutines.FlowPreview::class)
123+class ChatViewModel(
124+ private val container: AppContainer,
125+ private val sessionId: String,
126+) : ViewModel() {
127+
128+ private val connection get() = container.activeConnection.value
129+
130+ val connected: Boolean get() = connection != null
131+
132+ private val _loading = MutableStateFlow(true)
133+ val loading: StateFlow<Boolean> = _loading.asStateFlow()
134+
135+ /** Set when the messages flow failed to load (and the list is empty), so the UI
136+ * can render a distinct error state instead of masquerading as an empty
137+ * conversation. Cleared on a successful non-empty emission. */
138+ private val _loadError = MutableStateFlow(false)
139+ val loadError: StateFlow<Boolean> = _loadError.asStateFlow()
140+
141+ /** Set when a re-fetch fails mid-conversation (messages already visible). Distinct
142+ * from [loadError] (which replaces the whole content with an error screen): this
143+ * surfaces as a persistent inline banner above the message list so a persistent
144+ * outage doesn't go silent after the one-shot snackbar. Cleared on the next
145+ * successful emission. */
146+ private val _loadErrorInline = MutableStateFlow(false)
147+ val loadErrorInline: StateFlow<Boolean> = _loadErrorInline.asStateFlow()
148+
149+ /** Tracks whether the messages flow has ever emitted a non-empty list, so
150+ * [messages]' retryWhen can decide whether a re-fetch failure should set
151+ * [loadError] (nothing shown yet → error screen) or just emit a snackbar
152+ * (already showing messages → don't replace the conversation with an error). */
153+ private var hasShownMessages = false
154+
155+ /** True once we've surfaced a snackbar for the current message-load failure streak.
156+ * Reset on any successful emission so a persistent failure doesn't spam a fresh
157+ * snackbar every retry cycle (every few seconds). */
158+ private var loadErrorSnackbarShown = false
159+
160+ /** Separate from [loading]: tracks the manual reconnect() flow so its spinner
161+ * doesn't conflict with the messages flow's loading state. */
162+ private val _reconnecting = MutableStateFlow(false)
163+ val reconnecting: StateFlow<Boolean> = _reconnecting.asStateFlow()
164+
165+ /** When true, the debounced draft collector skips persisting an empty draft so
166+ * [send]'s deliberate non-persistence of the cleared draft isn't undone by the
167+ * debounce timer firing before the send completes. */
168+ private val suppressDraftPersist = java.util.concurrent.atomic.AtomicBoolean(false)
169+
170+ /** The draft value this VM last persisted, so the drafts observer can tell its own
171+ * (debounced, possibly stale) echo apart from a genuine external write. Without this
172+ * a debounced persist of an older value could echo back and overwrite newer text the
173+ * user has since typed. */
174+ @Volatile private var lastPersistedDraft: String? = null
175+
176+ /** Per-catalog reload triggers: incrementing one causes [observeCatalog]'s
177+ * collectLatest to cancel any in-flight fetch and start a fresh one, so a
178+ * manual reload supersedes a stale observeCatalog fetch. */
179+ private val _modelsReload = MutableStateFlow(0)
180+ private val _agentsReload = MutableStateFlow(0)
181+ private val _commandsReload = MutableStateFlow(0)
182+
183+ /** An optimistically-injected user message shown immediately on send, before the server
184+ * echoes the real message back via SSE. Removed once a matching UserMessage arrives. */
185+ data class OptimisticEntry(val tempId: String, val text: String, val timestamp: Long, val failed: Boolean)
186+
187+ private val _optimisticMessages = MutableStateFlow<List<OptimisticEntry>>(emptyList())
188+
189+ /** SSE-driven message stream with retry/backoff and loading/error side effects. Combined
190+ * with [_optimisticMessages] by [messages] so the user sees their outgoing prompt instantly. */
191+ private val sseMessages: Flow<List<MessageWithParts>> =
192+ container.activeConnection
193+ .flatMapLatest { conn ->
194+ conn?.repository?.observeMessages(sessionId) ?: flowOf(emptyList())
195+ }
196+ .onEach {
197+ _loading.value = false
198+ // A value flowed through: the stream recovered, so allow the next distinct
199+ // failure to surface a fresh snackbar again.
200+ loadErrorSnackbarShown = false
201+ // Clear the error on ANY successful emission, including an empty list. An empty
202+ // session that hit one transient error would otherwise stay latched on the
203+ // "Failed to load / Retry" screen forever, because the later successful EMPTY
204+ // emission couldn't clear the flag. hasShownMessages stays gated on non-empty
205+ // (an empty session has genuinely shown nothing yet).
206+ _loadError.value = false
207+ _loadErrorInline.value = false
208+ if (it.isNotEmpty()) {
209+ hasShownMessages = true
210+ }
211+ // Reconcile optimistic messages: drop any whose text now matches a real SSE
212+ // UserMessage (the server echoed the prompt back). Matching by trimmed text,
213+ // removing the oldest match first so duplicate texts are handled in order.
214+ reconcileOptimistic(it)
215+ }
216+ .retryWhen { cause, attempt ->
217+ _loading.value = false
218+ // Only surface the persistent error state when there's nothing to
219+ // show — if we already have messages, a transient re-fetch failure
220+ // is better surfaced as a snackbar (via errorEvents) than by
221+ // replacing the visible conversation with an error screen.
222+ if (!hasShownMessages) _loadError.value = true
223+ // Mid-conversation failure: surface a persistent inline banner so
224+ // the user knows the stream is broken (the one-shot snackbar alone
225+ // reads as "recovered" once it dismisses). Cleared on recovery.
226+ if (hasShownMessages) _loadErrorInline.value = true
227+ // Surface the snackbar only once per failure streak — retryWhen loops on a
228+ // backoff, so emitting here unconditionally would spam a new snackbar every
229+ // retry on a persistent load failure.
230+ if (!loadErrorSnackbarShown) {
231+ _errorEvents.trySend(ChatError(container.friendlyError(cause)))
232+ loadErrorSnackbarShown = true
233+ }
234+ // Exponential backoff capped at retryMaxDelayMs so a persistently failing
235+ // server is retried with growing delays (retryInitialDelayMs, then doubling)
236+ // instead of hammered at a fixed interval forever. Clamp the shift so a long
237+ // failure streak can't overflow the Long delay. Apply symmetric jitter
238+ // (matching withRetry's jitteredBackoff) so concurrent retrying flows (e.g.
239+ // messages + catalogs after a server blip) don't retry in lockstep and
240+ // thundering-herd the recovering server.
241+ val shift = attempt.coerceAtMost(16L).toInt()
242+ val baseDelay = (NetworkConfig.retryInitialDelayMs shl shift)
243+ .coerceAtMost(NetworkConfig.retryMaxDelayMs)
244+ val jitter = ((baseDelay * NetworkConfig.retryJitterFactor) *
245+ (kotlin.random.Random.nextDouble() * 2 - 1)).toLong()
246+ val backoffMs = (baseDelay + jitter).coerceAtLeast(0)
247+ delay(backoffMs)
248+ true
249+ }
250+
251+ val messages: StateFlow<List<MessageWithParts>> =
252+ combine(sseMessages, _optimisticMessages) { sse, optimistic ->
253+ mergeOptimistic(sse, optimistic)
254+ }.stateIn(
255+ scope = viewModelScope,
256+ started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
257+ initialValue = emptyList(),
258+ )
259+
260+ /** Whether the conversation has any messages. Derived separately so the top bar
261+ * (share button enabled state) can observe this cheap boolean instead of the
262+ * full messages list, avoiding per-token recomposition of the app bar during
263+ * streaming. */
264+ val hasMessages: StateFlow<Boolean> = messages
265+ .map { it.isNotEmpty() }
266+ .stateIn(
267+ scope = viewModelScope,
268+ started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
269+ initialValue = false,
270+ )
271+
272+ /** Map of optimistic message tempId → failed flag, so the UI can render a "Sending…"
273+ * or "Failed to send" indicator on the corresponding message bubble. */
274+ val optimisticStatuses: StateFlow<Map<String, Boolean>> = _optimisticMessages
275+ .map { entries -> entries.associate { it.tempId to it.failed } }
276+ .stateIn(
277+ scope = viewModelScope,
278+ started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
279+ initialValue = emptyMap(),
280+ )
281+
282+ /** Merge SSE-driven messages with optimistic entries. An optimistic entry whose trimmed
283+ * text matches a real [UserMessage] in [sse] is dropped (the server echoed it back).
284+ * Remaining entries are appended as synthetic [UserMessage]s at the end. Failed entries
285+ * are kept so the user sees the failed indicator until they retry or dismiss.
286+ *
287+ * Only the OLDEST matching entry per real text is dropped, so duplicate prompts (the same
288+ * text sent twice) each match their own server-echoed UserMessage instead of both
289+ * vanishing on the first echo. */
290+ private fun mergeOptimistic(
291+ sse: List<MessageWithParts>,
292+ optimistic: List<OptimisticEntry>,
293+ ): List<MessageWithParts> {
294+ if (optimistic.isEmpty()) return sse
295+ // Collect the text of real user messages so optimistic entries can be matched.
296+ val realUserTexts = mutableSetOf<String>()
297+ for (msg in sse) {
298+ if (msg.info !is UserMessage) continue
299+ val text = msg.parts.filterIsInstance<TextPart>()
300+ .joinToString("\n\n") { it.text }
301+ .trim()
302+ if (text.isNotEmpty()) realUserTexts.add(text)
303+ }
304+ val surviving = dropOldestMatches(optimistic, realUserTexts)
305+ if (surviving.isEmpty()) return sse
306+ return sse + surviving.map { it.toMessageWithParts() }
307+ }
308+
309+ /** Remove optimistic entries whose text now matches a real user message delivered by SSE.
310+ * Called from the SSE flow's onEach so stale optimistic entries are cleaned up promptly.
311+ * Only the OLDEST matching entry per real text is removed, so duplicate prompts (the same
312+ * text sent twice) each match their own server-echoed UserMessage instead of both
313+ * vanishing on the first echo. */
314+ private fun reconcileOptimistic(sse: List<MessageWithParts>) {
315+ if (_optimisticMessages.value.isEmpty()) return
316+ val realUserTexts = mutableSetOf<String>()
317+ for (msg in sse) {
318+ if (msg.info !is UserMessage) continue
319+ val text = msg.parts.filterIsInstance<TextPart>()
320+ .joinToString("\n\n") { it.text }
321+ .trim()
322+ if (text.isNotEmpty()) realUserTexts.add(text)
323+ }
324+ if (realUserTexts.isEmpty()) return
325+ _optimisticMessages.update { entries -> dropOldestMatches(entries, realUserTexts) }
326+ }
327+
328+ /** Drop the first (oldest) entry whose trimmed text matches each real user text, once per
329+ * text. A second optimistic entry with the same text survives until a second matching
330+ * UserMessage arrives, so duplicate prompts aren't both cleared by the first echo. */
331+ private fun dropOldestMatches(
332+ entries: List<OptimisticEntry>,
333+ realUserTexts: Set<String>,
334+ ): List<OptimisticEntry> {
335+ if (entries.isEmpty() || realUserTexts.isEmpty()) return entries
336+ val remaining = realUserTexts.toMutableSet()
337+ val result = ArrayList<OptimisticEntry>(entries.size)
338+ for (entry in entries) {
339+ val text = entry.text.trim()
340+ if (text in remaining) {
341+ remaining.remove(text)
342+ } else {
343+ result.add(entry)
344+ }
345+ }
346+ return result
347+ }
348+
349+ /** Convert an optimistic entry to a [MessageWithParts] for display. */
350+ private fun OptimisticEntry.toMessageWithParts(): MessageWithParts = MessageWithParts(
351+ info = UserMessage(
352+ id = tempId,
353+ sessionID = sessionId,
354+ time = soy.iko.opencode.data.model.TimeInfo(created = timestamp),
355+ ),
356+ parts = listOf(TextPart(id = "$tempId-text", text = text)),
357+ )
358+
359+ /** The agent's current task plan (the latest `todowrite`), surfaced so the chat can pin a
360+ * live progress checklist above the composer. distinctUntilChanged so a per-token messages
361+ * emission that doesn't change the plan doesn't recompose the bar.
362+ *
363+ * A [TodoPlanCache] memoizes the scan against the input list's identity, so a `combine`
364+ * re-evaluation that re-emits the same list instance (e.g. when the optimistic-messages
365+ * StateFlow updates without an SSE change) skips the O(messages×parts) re-scan. */
366+ private val todoPlanCache = TodoPlanCache()
367+ val todoPlan: StateFlow<List<TodoItem>> = messages
368+ .map { todoPlanCache.plan(it) }
369+ // Run the scan off the main thread: it's an O(messages×parts) scan (worst case
370+ // when the conversation has no todowrite) and runs on every conflated messages emission.
371+ // distinctUntilChanged is downstream so it dedupes the result but doesn't prevent the
372+ // scan — without flowOn this scans on Main.immediate per streamed token → jank.
373+ .flowOn(Dispatchers.Default)
374+ .distinctUntilChanged()
375+ .stateIn(
376+ scope = viewModelScope,
377+ started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
378+ initialValue = emptyList(),
379+ )
380+
381+ private val _running = MutableStateFlow(false)
382+ val running: StateFlow<Boolean> = _running.asStateFlow()
383+
384+ /**
385+ * True when the SSE stream dropped mid-run (a transient [Disconnected] while a run is
386+ * active). The UI uses this to switch the working indicator to a "stream interrupted /
387+ * reconnecting" visual instead of clearing [_running] outright — the run is still active
388+ * on the server, so showing it as finished would mislead the user into typing a follow-up
389+ * or navigating away. Cleared when the stream reconnects ([Connected]) or when a hard
390+ * failure ([Failed]/[AuthFailed]) genuinely clears [_running]. See the state watcher in
391+ * [init]. */
392+ private val _streamInterrupted = MutableStateFlow(false)
393+ val streamInterrupted: StateFlow<Boolean> = _streamInterrupted.asStateFlow()
394+
395+ /**
396+ * Wall-clock millis at which the current run started (0L when no run is active). Drives
397+ * the typing row's elapsed timer so it survives LazyColumn recycling — a local `remember`
398+ * in the row resets to 0:00 when the row is disposed and scrolled back into view. Stamped
399+ * only on a real false→[beginRun] transition (send/run/init), not on SSE-reconnect relight
400+ * (which continues an existing run and should keep its original elapsed time).
401+ */
402+ private val _runStartMs = MutableStateFlow(0L)
403+ val runStartMs: StateFlow<Long> = _runStartMs.asStateFlow()
404+
405+ /**
406+ * Atomically transition `_running` false→true and stamp the run-start timestamp. Returns
407+ * false (with no state change) when a run is already in flight, so callers can surface a
408+ * "busy" message instead of silently dropping the action. Centralizes the transition so
409+ * every run-start path captures [runStartMs] consistently.
410+ */
411+ private fun beginRun(): Boolean {
412+ if (!_running.compareAndSet(false, true)) return false
413+ _runStartMs.value = System.currentTimeMillis()
414+ return true
415+ }
416+
417+ /** True once the current run ended via SessionIdle/SessionError, until the next run starts
418+ * (a send) or an SSE reconnect. Gates the run-indicator re-light below: isRunActivity
419+ * returns true for ANY message.part.updated, so without this a trailing/replayed part
420+ * arriving after a legitimate SessionIdle would re-light the "working" indicator with no
421+ * further idle to clear it — a stuck spinner + foreground service. Cleared on reconnect so
422+ * genuine reconnect-recovery re-lighting (its actual purpose) still works. */
423+ @Volatile private var runEndedByIdle = false
424+
425+ /** The in-flight REST title resolution for the current connection. Launched from — and
426+ * cancelled by — the per-connection reset block so it can't observe or restore a previous
427+ * server's stale title. */
428+ private var sessionTitleJob: Job? = null
429+
430+ /** True while an abort REST call is in flight, so the Stop button can show a
431+ * spinner and prevent double-taps from firing a second abort. */
432+ private val _aborting = MutableStateFlow(false)
433+ val aborting: StateFlow<Boolean> = _aborting.asStateFlow()
434+
435+ /** A follow-up the user typed while a run was active. send() queues it here and
436+ * auto-sends once the run completes (SessionIdle), so the user isn't blocked with
437+ * no way to send and no indication why the Send button is gone. */
438+ private val _queuedFollowUp = MutableStateFlow<String?>(null)
439+ val queuedFollowUp: StateFlow<String?> = _queuedFollowUp.asStateFlow()
440+
441+ /** The text of the most recently-sent user prompt (captured in [send]), so an undo-able
442+ * Stop can re-send it. Only text-bearing prompts are tracked — an image-only prompt has
443+ * nothing to re-send text-wise. Cleared when a new run starts with different text. */
444+ private var lastSentPrompt: String? = null
445+
446+ /** One-shot event signals for an undoable Stop. The UI collects this and shows an
447+ * "Stopped — Undo" snackbar; tapping Undo calls [resendLastPrompt]. A Channel so an
448+ * emit before the UI subscribes is still delivered. */
449+ private val _stopUndoEvents = Channel<String?>(Channel.CONFLATED)
450+ val stopUndoEvents: Flow<String?> = _stopUndoEvents.receiveAsFlow()
451+
452+ /** One-shot error events surfaced as snackbars. A Channel (not SharedFlow) so an event
453+ * emitted before the UI subscribes is buffered and still delivered — a SharedFlow with
454+ * replay=0 would drop it (e.g. a VM-init catalog fetch failing before first
455+ * composition). Each event is delivered exactly once and not replayed to a
456+ * re-subscribing collector, so rotation doesn't re-show a stale snackbar. */
457+ private val _errorEvents = Channel<ChatError>(
458+ capacity = NetworkConfig.snackbarEventBufferCapacity,
459+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
460+ )
461+ val errorEvents: Flow<ChatError> = _errorEvents.receiveAsFlow()
462+
463+ /** One-shot events signaling a user-initiated delete was scheduled and is undoable in-place.
464+ * The ChatScreen collects these to show an Undo snackbar over the conversation the user is
465+ * already viewing, instead of navigating away first and surfacing Undo on the session list.
466+ * Carries the session id so a collector using `collectLatest` can withdraw a superseded
467+ * delete (see [cancelSessionDelete]) — a `Unit` event would leave the prior session's
468+ * deferred REST delete firing with no reachable Undo. */
469+ private val _deleteUndoEvents = MutableSharedFlow<String>(
470+ extraBufferCapacity = 1,
471+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
472+ )
473+ val deleteUndoEvents: SharedFlow<String> = _deleteUndoEvents.asSharedFlow()
474+
475+ private val _models = MutableStateFlow<List<ModelOption>>(emptyList())
476+ val models: StateFlow<List<ModelOption>> = _models.asStateFlow()
477+
478+ private val _modelsLoading = MutableStateFlow(true)
479+ val modelsLoading: StateFlow<Boolean> = _modelsLoading.asStateFlow()
480+
481+ private val _modelsError = MutableStateFlow(false)
482+ val modelsError: StateFlow<Boolean> = _modelsError.asStateFlow()
483+
484+ private val _selectedModel = MutableStateFlow<ModelOption?>(null)
485+ val selectedModel: StateFlow<ModelOption?> = _selectedModel.asStateFlow()
486+
487+ val connectionState: StateFlow<EventStreamClient.ConnectionState> =
488+ container.activeConnection
489+ .flatMapLatest { it?.events?.state ?: flowOf(EventStreamClient.ConnectionState.Disconnected) }
490+ .stateIn(
491+ scope = viewModelScope,
492+ started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
493+ initialValue = EventStreamClient.ConnectionState.Disconnected,
494+ )
495+
496+ /** SSE reconnect-attempt count for the active connection, surfaced so the chat's
497+ * connection banner can show "Reconnecting (attempt N)…" during a sustained outage. */
498+ val reconnectAttempts: StateFlow<Int> =
499+ container.activeConnection
500+ .flatMapLatest { it?.events?.reconnectAttempts ?: flowOf(0) }
501+ .stateIn(
502+ scope = viewModelScope,
503+ started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
504+ initialValue = 0,
505+ )
506+
507+ private val _pendingPermission = MutableStateFlow<Permission?>(null)
508+ val pendingPermission: StateFlow<Permission?> = _pendingPermission.asStateFlow()
509+
510+ /** Backlog indicator for the permission dialog (see [PermissionProgress]). */
511+ private val _permissionProgress = MutableStateFlow(PermissionProgress(0, 0))
512+ val permissionProgress: StateFlow<PermissionProgress> = _permissionProgress.asStateFlow()
513+
514+ /** Max number of permissions simultaneously pending since the queue was last empty. Reset
515+ * to 0 when the queue drains, so a fresh burst starts its count over. */
516+ private var maxPendingSeen = 0
517+
518+ // Unanswered permission requests keyed by id, insertion-ordered. The dialog shows the most
519+ // recent; when one is answered/replied — or a response fails — we fall back to the next so a
520+ // request that arrived while another was in flight isn't silently dropped (its tool run would
521+ // otherwise stay paused server-side with no UI affordance). Only touched on the VM's main
522+ // dispatcher (event collector + viewModelScope launches), so no extra synchronization is needed.
523+ private val pendingPermissions = LinkedHashMap<String, Permission>()
524+
525+ // Session-scoped permission grants (Allow-for-this-session). Each entry is a (type, patternText)
526+ // pair the user approved with PermissionResponse.SESSION. Subsequent matching requests are
527+ // auto-responded ONCE without showing the dialog, so e.g. a user granting "read" for one path
528+ // isn't re-prompted for every other file read in the same conversation. Cleared on connection
529+ // change (see the activeConnection collector) so it never leaks across servers. The server only
530+ // knows once/always/reject; SESSION is a client-side scope that maps to ONCE on the wire.
531+ private val sessionAllowed = mutableSetOf<Pair<String, String>>()
532+
533+ private fun enqueuePermission(permission: Permission) {
534+ // Auto-respond to a permission whose (type, pattern) the user previously granted for this
535+ // session, instead of showing the dialog again. Resolved immediately (not via the queue) so
536+ // the tool run isn't paused waiting for a dialog the user will never see.
537+ val type = permission.type.orEmpty()
538+ val pattern = permission.patternText.orEmpty()
539+ if (type.isNotEmpty() && sessionAllowed.contains(type to pattern)) {
540+ respondPermission(permission, PermissionResponse.ONCE)
541+ return
542+ }
543+ pendingPermissions[permission.id] = permission
544+ _pendingPermission.value = pendingPermissions.values.lastOrNull()
545+ updatePermissionProgress()
546+ }
547+
548+ private fun resolvePermission(id: String) {
549+ pendingPermissions.remove(id)
550+ _pendingPermission.value = pendingPermissions.values.lastOrNull()
551+ updatePermissionProgress()
552+ }
553+
554+ private fun clearPermissions() {
555+ pendingPermissions.clear()
556+ _pendingPermission.value = null
557+ updatePermissionProgress()
558+ }
559+
560+ /** Recompute the "N of M" backlog indicator. [maxPendingSeen] grows with the queue and
561+ * resets only when it drains, so position = total - remaining + 1 advances as requests are
562+ * answered. */
563+ private fun updatePermissionProgress() {
564+ val remaining = pendingPermissions.size
565+ if (remaining == 0) {
566+ maxPendingSeen = 0
567+ _permissionProgress.value = PermissionProgress(0, 0)
568+ return
569+ }
570+ if (remaining > maxPendingSeen) maxPendingSeen = remaining
571+ _permissionProgress.value = PermissionProgress(
572+ position = maxPendingSeen - remaining + 1,
573+ total = maxPendingSeen,
574+ )
575+ }
576+
577+ private val _agents = MutableStateFlow<List<Agent>>(emptyList())
578+ val agents: StateFlow<List<Agent>> = _agents.asStateFlow()
579+
580+ private val _agentsLoading = MutableStateFlow(true)
581+ val agentsLoading: StateFlow<Boolean> = _agentsLoading.asStateFlow()
582+
583+ private val _agentsError = MutableStateFlow(false)
584+ val agentsError: StateFlow<Boolean> = _agentsError.asStateFlow()
585+
586+ private val _selectedAgent = MutableStateFlow<String?>(null)
587+ val selectedAgent: StateFlow<String?> = _selectedAgent.asStateFlow()
588+
589+ private val _commands = MutableStateFlow<List<Command>>(emptyList())
590+ val commands: StateFlow<List<Command>> = _commands.asStateFlow()
591+
592+ private val _commandsLoading = MutableStateFlow(true)
593+ val commandsLoading: StateFlow<Boolean> = _commandsLoading.asStateFlow()
594+
595+ private val _commandsError = MutableStateFlow(false)
596+ val commandsError: StateFlow<Boolean> = _commandsError.asStateFlow()
597+
598+ private val _sessionTitle = MutableStateFlow<String?>(null)
599+ val sessionTitle: StateFlow<String?> = _sessionTitle.asStateFlow()
600+
601+ /** Set when the current session is deleted via SSE, so the UI can navigate away. */
602+ private val _sessionDeleted = MutableStateFlow(false)
603+ val sessionDeleted: StateFlow<Boolean> = _sessionDeleted.asStateFlow()
604+
605+ /** The text of the last send that failed, surfaced so the UI can offer a retry. */
606+ private val _failedDraft = MutableStateFlow<String?>(null)
607+ val failedDraft: StateFlow<String?> = _failedDraft.asStateFlow()
608+
609+ /** The idempotency key of the last failed online send, stashed alongside [_failedDraft] so
610+ * [retryFailed] re-submits with the SAME key. A retry after a lost response is then
611+ * deduplicated server-side instead of starting a duplicate agent run. */
612+ @Volatile private var failedIdempotencyKey: String? = null
613+
614+ /** Per-session draft, persisted so it survives navigation/process death. */
615+ private val _draft = MutableStateFlow(container.draftStore.get(sessionId))
616+ val draft: StateFlow<String> = _draft.asStateFlow()
617+
618+ /** A pending quote-reply preview (the raw text the user quoted), shown as a dismissible
619+ * card above the composer so the user can see and cancel the quote before sending. Cleared
620+ * on send or via [cancelQuoteReply]. The actual `>` prefixed lines are still folded into
621+ * the draft for the server; this state is purely for the visual preview chip. */
622+ private val _pendingQuote = MutableStateFlow<String?>(null)
623+ val pendingQuote: StateFlow<String?> = _pendingQuote.asStateFlow()
624+
625+ /** Messages composed for this session while offline/disconnected, queued in the outbox and
626+ * awaiting an automatic flush on reconnect. Surfaced so the composer can show a "queued"
627+ * chip with a discard/send-now affordance. */
628+ val outbox: StateFlow<List<OutboxMessage>> =
629+ container.outboxStore.messages
630+ .map { list -> list.filter { it.sessionId == sessionId } }
631+ .stateIn(
632+ scope = viewModelScope,
633+ started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
634+ initialValue = emptyList(),
635+ )
636+
637+ /** Attachments staged for the next prompt (images/files). Persisted per-session (as their
638+ * self-contained base64 data URLs) via [AttachmentDraftStore] so an interrupted compose
639+ * survives process death — the source content Uris wouldn't survive, but the data URLs
640+ * do. Cleared on a successful send. */
641+ private val _attachments = MutableStateFlow<List<PendingAttachment>>(emptyList())
642+ val attachments: StateFlow<List<PendingAttachment>> = _attachments.asStateFlow()
643+
644+ // Count of picks currently being read + base64-encoded off the main thread, so the
645+ // composer can show a staging placeholder immediately (chips only materialize once done).
646+ // Exposed as StateFlows (not Compose state) so they survive a config change: the staging
647+ // work runs on viewModelScope (which survives rotation), so the counters must live on the
648+ // VM too — remember { mutableStateOf(0) } in the composable would reset to 0 on rotation
649+ // and lose the staging chip mid-encode.
650+ private val _stagingCount = MutableStateFlow(0)
651+ val stagingCount: StateFlow<Int> = _stagingCount.asStateFlow()
652+ // Total number of files still being staged across all in-flight batches, so the
653+ // staging chip can show "Staging N files…" rather than a bare indeterminate spinner.
654+ private val _stagingFileTotal = MutableStateFlow(0)
655+ val stagingFileTotal: StateFlow<Int> = _stagingFileTotal.asStateFlow()
656+
657+ /** True while a revert checkpoint is active for this session (messages after it are hidden
658+ * server-side). Drives the "reverted" banner with its Undo. */
659+ private val _reverted = MutableStateFlow(false)
660+ val reverted: StateFlow<Boolean> = _reverted.asStateFlow()
661+
662+ /** True when the revert was triggered by an Edit action (vs a bare Revert). Drives the
663+ * banner's copy so it reads "Editing message" rather than "Reverted", making the composer's
664+ * prefilled text unambiguous. Cleared on unrevert and on send. */
665+ private val _editing = MutableStateFlow(false)
666+ val editing: StateFlow<Boolean> = _editing.asStateFlow()
667+
668+ /** The diff of the active revert checkpoint, when the server includes one. Shown in the
669+ * revert banner so the user can see what changed before deciding to undo. Null when the
670+ * session isn't reverted or the server didn't send a diff. */
671+ private val _revertDiff = MutableStateFlow<String?>(null)
672+ val revertDiff: StateFlow<String?> = _revertDiff.asStateFlow()
673+
674+ /** The active public share URL for this session, or null when not shared. */
675+ private val _shareUrl = MutableStateFlow<String?>(null)
676+ val shareUrl: StateFlow<String?> = _shareUrl.asStateFlow()
677+
678+ /** One-shot events carrying a freshly-created share URL so the UI can copy/share it. */
679+ private val _shareLinkEvents = Channel<String>(
680+ capacity = NetworkConfig.snackbarEventBufferCapacity,
681+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
682+ )
683+ val shareLinkEvents: Flow<String> = _shareLinkEvents.receiveAsFlow()
684+
685+ /** One-shot events carrying the id of a session freshly branched from a message, so the
686+ * UI can navigate to it. */
687+ private val _branchEvents = Channel<String>(
688+ capacity = NetworkConfig.snackbarEventBufferCapacity,
689+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
690+ )
691+ val branchEvents: Flow<String> = _branchEvents.receiveAsFlow()
692+
693+ init {
694+ // DraftStore loads SharedPreferences asynchronously; on a cold start the
695+ // synchronous get() above returns "" until the background load completes.
696+ // Observe the ready signal and re-seed the draft so it appears in the UI.
697+ viewModelScope.launch {
698+ container.draftStore.ready.collect { ready ->
699+ if (!ready) return@collect
700+ if (_draft.value.isEmpty()) {
701+ _draft.value = container.draftStore.get(sessionId)
702+ }
703+ // Recover a follow-up queued before process death (its in-memory StateFlow
704+ // is gone). If the input is now free, drop it back there for review/send and
705+ // consume the stored copy; otherwise re-queue it so it auto-sends when the
706+ // current run next idles. Guarded on == null so it never clobbers a
707+ // follow-up the user queued after opening this session.
708+ val savedFollowUp = container.draftStore.getFollowUp(sessionId)
709+ if (savedFollowUp.isNotEmpty() && _queuedFollowUp.value == null) {
710+ if (_draft.value.isEmpty()) {
711+ _draft.value = savedFollowUp
712+ container.draftStore.flushFollowUp(sessionId, "")
713+ } else {
714+ _queuedFollowUp.value = savedFollowUp
715+ }
716+ }
717+ }
718+ }
719+ // Observe the drafts store for external mutations to this session's draft
720+ // (e.g. a share injected via draftStore.set in two-pane mode, or setImmediate
721+ // before navigation in single-pane mode). Without this, a share injected into
722+ // an already-open session never appears in the input field — the init-time
723+ // read and the ready-only re-seed miss it when the draft is non-empty.
724+ // The VM's own debounced persist writes the same value _draft already holds,
725+ // so those updates are no-ops here (storeValue == _draft.value). The
726+ // suppressDraftPersist guard prevents a stale store value from clobbering
727+ // the deliberate draft clear during a send (the store still holds the
728+ // pre-send draft until the send resolves).
729+ viewModelScope.launch {
730+ container.draftStore.drafts.collect { drafts ->
731+ // Ignore emissions until the initial disk load completes: the load itself
732+ // emits the persisted snapshot, and applying it here would clobber text the
733+ // user typed during the async load (the `ready` observer above seeds an
734+ // empty draft). After ready, emissions are external writes.
735+ if (!container.draftStore.ready.value) return@collect
736+ val storeValue = drafts[sessionId].orEmpty()
737+ // Ignore this VM's own (debounced, possibly stale) persistence echo — only a
738+ // genuine external write (e.g. a two-pane share injection) should overwrite
739+ // the live draft.
740+ if (storeValue == lastPersistedDraft) return@collect
741+ if (storeValue != _draft.value && !suppressDraftPersist.get()) {
742+ _draft.value = storeValue
743+ }
744+ }
745+ }
746+ }
747+
748+ init {
749+ // Restore attachments staged before process death. They were persisted as
750+ // self-contained data URLs, so they re-send and (for images) re-preview intact.
751+ // Guarded on empty so it never clobbers attachments the user staged after opening.
752+ viewModelScope.launch {
753+ val restored = runCatchingCancellable { container.attachmentDraftStore.load(sessionId) }
754+ .getOrDefault(emptyList())
755+ if (restored.isNotEmpty() && _attachments.value.isEmpty()) {
756+ _attachments.value = restored.map { it.toPending() }
757+ }
758+ }
759+ }
760+
761+ fun selectModel(option: ModelOption) { _selectedModel.value = option }
762+
763+ fun selectAgent(name: String?) { _selectedAgent.value = name }
764+
765+ fun reloadModels() {
766+ val conn = connection ?: return
767+ viewModelScope.launch {
768+ conn.api.invalidateProvidersCache()
769+ _modelsReload.value++
770+ }
771+ }
772+
773+ fun reloadAgents() {
774+ val conn = connection ?: return
775+ viewModelScope.launch {
776+ conn.api.invalidateAgentsCache()
777+ _agentsReload.value++
778+ }
779+ }
780+
781+ fun reloadCommands() {
782+ val conn = connection ?: return
783+ viewModelScope.launch {
784+ conn.api.invalidateCommandsCache()
785+ _commandsReload.value++
786+ }
787+ }
788+
789+ /**
790+ * Shared helper for init-block catalog observers: re-fetches whenever the active
791+ * connection or the catalog's reload trigger changes, invoking [onNull] to clear
792+ * state on null so stale data from the old server doesn't persist. The
793+ * [reloadTrigger] is merged with [activeConnection] so a manual reload (via
794+ * reloadModels/reloadAgents/reloadCommands) cancels any in-flight observe fetch
795+ * via collectLatest, preventing a stale observe result from overwriting a fresh
796+ * reload result. Failure is non-fatal — the error flag is surfaced to the UI.
797+ */
798+ private fun <T> observeCatalog(
799+ tag: String,
800+ loading: MutableStateFlow<Boolean>,
801+ error: MutableStateFlow<Boolean>,
802+ fetch: suspend (soy.iko.opencode.data.network.OpencodeApiClient) -> T,
803+ onSuccess: suspend (T) -> Unit,
804+ onNull: () -> Unit,
805+ reloadTrigger: StateFlow<Int>,
806+ ) {
807+ viewModelScope.launch {
808+ merge(container.activeConnection, reloadTrigger).collectLatest { _ ->
809+ val conn = container.activeConnection.value
810+ if (conn == null) { onNull(); loading.value = false; error.value = false; return@collectLatest }
811+ loading.value = true
812+ error.value = false
813+ runCatchingCancellable { fetch(conn.api) }
814+ // fetch() does HTTP via withRetry, so the failure can be a
815+ // ClientRequestException whose message embeds the full request URL
816+ // (may contain auth/paths). Log only a scrubbed summary.
817+ .onFailure { Log.w("ChatViewModel", "Failed to load $tag: ${safeExceptionSummary(it)}"); error.value = true }
818+ .getOrNull()?.let { onSuccess(it) }
819+ loading.value = false
820+ }
821+ }
822+ }
823+
824+ fun updateDraft(text: String) {
825+ _draft.value = text
826+ }
827+
828+ init {
829+ // Debounce draft persistence so we don't write to disk on every keystroke.
830+ // Skip persisting empty drafts that were set by send() (suppressed via
831+ // suppressDraftPersist) — send() clears the in-memory draft immediately for
832+ // UI feedback but deliberately doesn't persist the clear until the send
833+ // succeeds, so a failed send can restore the draft.
834+ viewModelScope.launch {
835+ _draft.drop(1).debounce(NetworkConfig.draftDebounceMs).collect { text ->
836+ if (text.isBlank() && suppressDraftPersist.get()) return@collect
837+ lastPersistedDraft = text
838+ runCatchingCancellable { container.draftStore.set(sessionId, text) }
839+ .onFailure { Log.w("ChatViewModel", "Failed to persist draft", it) }
840+ }
841+ }
842+ // Reset per-connection state when the active server changes so stale spinners,
843+ // permission dialogs, errors, and agent selections from the old server don't
844+ // persist into the new one.
845+ viewModelScope.launch {
846+ container.activeConnection.collectLatest { conn ->
847+ if (conn == null) {
848+ // The connection dropped (e.g. disconnect() mid-run). No SSE stream will
849+ // arrive to deliver SessionIdle/SessionError, so reset the run state here —
850+ // otherwise the working spinner sticks on, ChatScreen keeps keepScreenOn +
851+ // the RunForegroundService alive, and the Stop button no-ops (abort() early-
852+ // returns with no connection) until a later reconnect happens to clear it.
853+ _running.value = false
854+ _streamInterrupted.value = false
855+ _aborting.value = false
856+ clearPermissions()
857+ sessionAllowed.clear()
858+ sessionTitleJob?.cancel()
859+ _revertDiff.value = null
860+ return@collectLatest
861+ }
862+ _running.value = false
863+ _streamInterrupted.value = false
864+ // Allow the re-light below to recover a still-running run after this (re)connect;
865+ // a run that actually finished during the outage emits no live parts, so this
866+ // won't spuriously re-light.
867+ runEndedByIdle = false
868+ clearPermissions()
869+ sessionAllowed.clear()
870+ _failedDraft.value = null
871+ _revertDiff.value = null
872+ // NOTE: deliberately not clearing _queuedFollowUp here. It's session-scoped
873+ // user intent that is now persisted; wiping it on every (re)connect — which
874+ // this collector does, including transient SSE drops and the cold-start
875+ // restore path — would drop a legitimately queued/recovered follow-up.
876+ _selectedAgent.value = null
877+ // Apply the user's persisted preferred-agent for this new session/load, so a
878+ // user who always runs a specific agent doesn't have to pick it every session.
879+ // No-op when the preference is empty (the server default applies). Read here
880+ // (not in the agent-catalog onSuccess) because the catalog loads once per
881+ // connection while this reset fires per-session-open; setting it once on
882+ // connect is enough and avoids re-applying it on every catalog refresh.
883+ runCatchingCancellable {
884+ val pref = container.settingsStore.preferredAgentName.first()
885+ if (pref.isNotEmpty()) _selectedAgent.value = pref
886+ }
887+ _sessionTitle.value = null
888+ // Resolve the app-bar title via REST for THIS connection. Launched from the same
889+ // block that just nulled _sessionTitle (and cancelled the previous fetch) so it can
890+ // never observe or restore the previous server's stale title — the old separate
891+ // collector could, racing that reset. The assignment is guarded on a still-null
892+ // title so a fresher SSE SessionUpdated (from the event collector below) wins.
893+ sessionTitleJob?.cancel()
894+ sessionTitleJob = viewModelScope.launch {
895+ runCatchingCancellable { conn.repository.listSessions() }
896+ .getOrNull()
897+ ?.firstOrNull { it.id == sessionId }
898+ ?.let { session ->
899+ if (_sessionTitle.value == null) {
900+ _sessionTitle.value = session.displayTitle
901+ _reverted.value = session.isReverted
902+ _revertDiff.value = session.revert?.diff?.takeIf { it.isNotBlank() }
903+ _shareUrl.value = session.share?.url?.takeIf { it.isNotBlank() }
904+ }
905+ }
906+ }
907+ try {
908+ // Loop the inner collect so a transient non-cancellation exception (a
909+ // malformed SSE event decoding error, an unexpected channel close)
910+ // doesn't terminate the event collector for this connection. Without
911+ // the loop, the catch returns the collectLatest lambda normally, so
912+ // collectLatest considers this connection's block complete and never
913+ // re-runs it — the chat would go permanently silent (no streaming,
914+ // no permission prompts, no title updates) until a server switch.
915+ while (currentCoroutineContext().isActive) {
916+ try {
917+ conn.events.events.collect { event ->
918+ if (SessionRepository.isIdle(event, sessionId)) {
919+ _running.value = false
920+ runEndedByIdle = true
921+ // Auto-send a queued follow-up once the previous run finishes,
922+ // so the user's drafted-while-running message isn't lost.
923+ // The follow-up is cleared *inside* send()'s launched
924+ // coroutine (via queuedFollowUpToClear), so a scope
925+ // cancellation between this point and the coroutine
926+ // starting drops the launch without wiping the persisted
927+ // follow-up — recovery on next session open instead of
928+ // silent data loss.
929+ val queued = _queuedFollowUp.value
930+ if (queued != null) {
931+ send(queued, includeAttachments = false, queuedFollowUpToClear = queued)
932+ }
933+ }
934+ if (SessionRepository.isError(event, sessionId)) {
935+ _running.value = false
936+ runEndedByIdle = true
937+ // The run errored, so the queued follow-up won't auto-send.
938+ // Restore it to the input (if free) so the user's typed text
939+ // isn't silently lost; otherwise just clear the queue.
940+ val queued = _queuedFollowUp.value
941+ if (!queued.isNullOrEmpty() && _draft.value.isEmpty()) {
942+ _draft.value = queued
943+ }
944+ setQueuedFollowUp(null)
945+ _errorEvents.trySend(ChatError(container.string(R.string.error_agent_reported)))
946+ }
947+ // Restore the run indicator if a live streaming event arrives while it's
948+ // cleared — e.g. an SSE reconnect mid-run reset _running to false, but the
949+ // agent is still working. Don't revive it once the user has hit Stop.
950+ val relightRunIndicator = SessionRepository.isRunActivity(event, sessionId) &&
951+ !_aborting.value && !_running.value && !runEndedByIdle
952+ if (relightRunIndicator) {
953+ _running.value = true
954+ }
955+ when (event) {
956+ is PermissionUpdated ->
957+ if (event.properties.sessionID == sessionId) enqueuePermission(event.properties)
958+ is PermissionReplied ->
959+ if (event.properties.sessionID == sessionId) event.properties.permissionID?.let { resolvePermission(it) }
960+ is SessionUpdated -> {
961+ val info = event.properties.info
962+ if (info != null && info.id == sessionId) {
963+ _sessionTitle.value = info.displayTitle
964+ _reverted.value = info.isReverted
965+ _revertDiff.value = info.revert?.diff?.takeIf { it.isNotBlank() }
966+ _shareUrl.value = info.share?.url?.takeIf { it.isNotBlank() }
967+ }
968+ }
969+ is SessionDeleted ->
970+ if (event.properties.info?.id == sessionId || event.properties.sessionID == sessionId) {
971+ _sessionDeleted.value = true
972+ }
973+ else -> {}
974+ }
975+ }
976+ } catch (e: CancellationException) {
977+ throw e
978+ } catch (e: Exception) {
979+ Log.w("ChatViewModel", "SSE event collector error, restarting", e)
980+ }
981+ }
982+ } catch (e: CancellationException) {
983+ throw e
984+ } catch (e: Exception) {
985+ Log.w("ChatViewModel", "SSE event collector loop error", e)
986+ }
987+ }
988+ }
989+ // Load the model catalog; preselect the server default. Failure is non-fatal —
990+ // sending with no model just uses the server's default agent/model.
991+ observeCatalog(
992+ tag = "model catalog",
993+ loading = _modelsLoading,
994+ error = _modelsError,
995+ fetch = { it.providers() },
996+ onSuccess = { resp ->
997+ val options = resp.toOptions()
998+ _models.value = options
999+ // Only (re)apply the default when there's no valid current selection. On
1000+ // re-fetch/reconnect/Retry the user's chosen model must survive — keep it as
1001+ // long as it's still present in the refreshed list. When there's no current
1002+ // selection, prefer the user's persisted default-model preference (if it's in
1003+ // the list) over the server default, so a user who always picks a specific
1004+ // model doesn't have to pick it every new session.
1005+ val current = _selectedModel.value
1006+ if (current == null || options.none { it.ref == current.ref }) {
1007+ val prefId = runCatchingCancellable {
1008+ container.settingsStore.preferredModelId.first()
1009+ }.getOrDefault("")
1010+ val preferredOption = if (prefId.isNotEmpty()) {
1011+ options.firstOrNull { it.modelID == prefId }
1012+ } else null
1013+ _selectedModel.value = preferredOption ?: resp.defaultOption(options)
1014+ }
1015+ },
1016+ onNull = { _models.value = emptyList(); _selectedModel.value = null },
1017+ reloadTrigger = _modelsReload,
1018+ )
1019+ // Load the agent catalog (non-fatal).
1020+ observeCatalog(
1021+ tag = "agent catalog",
1022+ loading = _agentsLoading,
1023+ error = _agentsError,
1024+ fetch = { it.agents() },
1025+ onSuccess = { _agents.value = it },
1026+ onNull = { _agents.value = emptyList() },
1027+ reloadTrigger = _agentsReload,
1028+ )
1029+ // Load the command catalog (non-fatal).
1030+ observeCatalog(
1031+ tag = "command catalog",
1032+ loading = _commandsLoading,
1033+ error = _commandsError,
1034+ fetch = { it.commands() },
1035+ onSuccess = { _commands.value = it },
1036+ onNull = { _commands.value = emptyList() },
1037+ reloadTrigger = _commandsReload,
1038+ )
1039+ // If the SSE stream drops mid-run, the run indicator would spin forever;
1040+ // reset it so the UI doesn't look stuck while the banner shows "Reconnecting…".
1041+ // On a *transient* drop (Disconnected) we don't clear _running — the run is still
1042+ // active on the server, and showing it as finished would mislead the user. Instead
1043+ // we set _streamInterrupted so the working row switches to a "reconnecting" visual.
1044+ // On a *hard* failure (Failed/AuthFailed) the run genuinely can't continue without
1045+ // user action, so _running is cleared. _streamInterrupted is cleared on Connected.
1046+ viewModelScope.launch {
1047+ container.activeConnection.collectLatest { conn ->
1048+ if (conn == null) return@collectLatest
1049+ try {
1050+ // Loop so a transient non-cancellation exception doesn't terminate the
1051+ // state watcher for this connection. Without the loop, a single thrown
1052+ // error returns the collectLatest lambda and the _running reset on
1053+ // Disconnected/Failed never fires again for this connection — a dropped
1054+ // stream leaves the working spinner (and the RunForegroundService) stuck.
1055+ while (currentCoroutineContext().isActive) {
1056+ try {
1057+ conn.events.state.collect { state ->
1058+ when (state) {
1059+ EventStreamClient.ConnectionState.Connected -> {
1060+ _streamInterrupted.value = false
1061+ }
1062+ EventStreamClient.ConnectionState.Disconnected -> {
1063+ // Transient: the stream dropped but the system is
1064+ // reconnecting. Flag interrupted so the working row
1065+ // shows a "reconnecting" state instead of vanishing.
1066+ if (_running.value) _streamInterrupted.value = true
1067+ }
1068+ EventStreamClient.ConnectionState.Failed,
1069+ EventStreamClient.ConnectionState.AuthFailed -> {
1070+ // Hard failure: the run can't continue without user
1071+ // action (fix credentials / server). Clear running and
1072+ // the interrupted flag so the UI doesn't look stuck.
1073+ _streamInterrupted.value = false
1074+ if (_running.value) _running.value = false
1075+ }
1076+ EventStreamClient.ConnectionState.Connecting -> {
1077+ // Initial connect or reconnect attempt; leave running
1078+ // as-is — interrupted (if set) stays until Connected.
1079+ }
1080+ }
1081+ }
1082+ } catch (e: CancellationException) {
1083+ throw e
1084+ } catch (e: Exception) {
1085+ Log.w("ChatViewModel", "SSE state collector error, restarting", e)
1086+ }
1087+ }
1088+ } catch (e: CancellationException) {
1089+ throw e
1090+ } catch (e: Exception) {
1091+ Log.w("ChatViewModel", "SSE state collector loop error", e)
1092+ }
1093+ }
1094+ }
1095+ }
1096+
1097+ /** Sends [text] together with any staged attachments; returns true on success so the
1098+ * caller can clear the draft only then. */
1099+ fun send(text: String): Boolean = send(text, includeAttachments = true)
1100+
1101+ /**
1102+ * Core send. [includeAttachments] is false for the internal auto-send paths (a queued
1103+ * follow-up, a retry) so staged attachments aren't silently re-sent with unrelated text.
1104+ *
1105+ * [queuedFollowUpToClear] is the text of a queued follow-up being auto-sent (from
1106+ * SessionIdle). When non-null, the follow-up is cleared from persistence *inside* the
1107+ * launched coroutine — so if [viewModelScope] is cancelled before the coroutine runs
1108+ * (e.g. the user navigates away at the exact moment SessionIdle arrives), the clear
1109+ * never executes and the follow-up remains persisted, recovered on the next open of
1110+ * this session. Clearing it before the launch (the prior approach) could lose the
1111+ * follow-up entirely: the launch would be dropped by cancellation but the persisted
1112+ * copy was already wiped.
1113+ */
1114+ @Suppress("CyclomaticComplexMethod")
1115+ private fun send(
1116+ text: String,
1117+ includeAttachments: Boolean,
1118+ idempotencyKey: String? = null,
1119+ queuedFollowUpToClear: String? = null,
1120+ ): Boolean {
1121+ val trimmed = text.trim()
1122+ val attachments = if (includeAttachments) _attachments.value else emptyList()
1123+ // An image-only prompt (attachments, no text) is valid; a blank prompt with nothing
1124+ // attached is not.
1125+ if (trimmed.isEmpty() && attachments.isEmpty()) return false
1126+ // Offline, or no active connection: queue the prompt to the outbox (flushed on
1127+ // reconnect) instead of failing the send. Device-offline is caught even when a stale
1128+ // connection object lingers, so a phone that lost signal mid-conversation still
1129+ // captures the message rather than bouncing it off a dead socket.
1130+ val conn = connection
1131+ if (conn == null || !container.isOnline.value) {
1132+ return enqueueOffline(trimmed, attachments)
1133+ }
1134+ if (!beginRun()) return false
1135+ // A new run is starting: reopen the re-light gate so this run's streamed parts keep the
1136+ // indicator lit (and a later SessionIdle re-closes it).
1137+ runEndedByIdle = false
1138+ // A plain manual Send of the auto-restored failed draft must reuse the stashed key too
1139+ // (retryFailed passes it explicitly; the composer's Send does not), so a lost-response
1140+ // re-send stays deduplicated server-side instead of starting a duplicate agent run.
1141+ // Capture the match BEFORE clearing _failedDraft just below.
1142+ val reuseKey = if (idempotencyKey == null && trimmed == _failedDraft.value?.trim()) {
1143+ failedIdempotencyKey
1144+ } else {
1145+ null
1146+ }
1147+ _failedDraft.value = null
1148+ // Stable idempotency key for THIS online attempt: a brand-new message gets a fresh key,
1149+ // while retryFailed() (or a manual re-send of the restored failed draft) re-submits with the
1150+ // key it stashed on the original failure — so a retry after a lost response is deduplicated
1151+ // server-side (no duplicate agent run).
1152+ val key = idempotencyKey ?: reuseKey ?: java.util.UUID.randomUUID().toString()
1153+ // Clear the staged attachments optimistically so the composer empties immediately;
1154+ // restore them if the send fails (mirrors the draft handling below).
1155+ if (attachments.isNotEmpty()) _attachments.value = emptyList()
1156+ // Clear the in-memory draft for the UI immediately, but don't persist the clear
1157+ // yet — if the send fails and the process dies before we restore, the draft
1158+ // would be lost forever. The persisted draft is cleared only on success.
1159+ // suppressDraftPersist prevents the debounced collector from persisting the
1160+ // empty draft before the send resolves.
1161+ //
1162+ // Only clear when the text being sent IS the current draft. send() is also
1163+ // invoked for auto-sent queued follow-ups (SessionIdle) and retryFailed(), where
1164+ // the user may have typed a NEW draft since — wiping it would lose that text.
1165+ if (_draft.value.trim() == trimmed) {
1166+ suppressDraftPersist.set(true)
1167+ _draft.value = ""
1168+ _pendingQuote.value = null
1169+ _editing.value = false
1170+ }
1171+ // Inject an optimistic user message so the outgoing prompt is visible immediately,
1172+ // before the server echoes it back via SSE. Removed by reconcileOptimistic once the
1173+ // real UserMessage arrives. Only for text-bearing prompts (an image-only prompt has
1174+ // no text to show).
1175+ val optimisticId = if (trimmed.isNotEmpty()) "__optimistic_${System.nanoTime()}" else null
1176+ if (optimisticId != null) {
1177+ // Clear any prior failed optimistic entry with the same text (a retry re-sends).
1178+ _optimisticMessages.update { entries -> entries.filterNot { it.failed && it.text.trim() == trimmed } }
1179+ _optimisticMessages.update { it + OptimisticEntry(optimisticId, trimmed, System.currentTimeMillis(), failed = false) }
1180+ }
1181+ // Remember the sent text so an undoable Stop can re-send it. Overwrite unconditionally:
1182+ // an image-only prompt has nothing to re-send, and leaving a prior text prompt tracked
1183+ // would make Stop-Undo silently re-send that unrelated earlier message.
1184+ lastSentPrompt = trimmed.takeIf { it.isNotEmpty() }
1185+ viewModelScope.launch {
1186+ // Clear a queued follow-up being auto-sent *inside* the launched coroutine, so a
1187+ // scope cancellation between send() being called and the coroutine starting drops
1188+ // the launch without wiping the persisted follow-up — the follow-up is then
1189+ // recovered on the next session open instead of being silently lost.
1190+ if (queuedFollowUpToClear != null) setQueuedFollowUp(null)
1191+ // Re-check the active connection: send() captured `conn` synchronously above, but a
1192+ // server switch between send() returning and this coroutine starting would send the
1193+ // prompt to the OLD (closed) server. Route to the outbox instead so the message is
1194+ // delivered to the correct server on reconnect, rather than failing against a stale
1195+ // connection. Reset _running (set by beginRun above) since no SSE streaming will
1196+ // follow an outbox enqueue — without this the run indicator would be stuck on.
1197+ if (container.activeConnection.value !== conn) {
1198+ enqueueOffline(trimmed, attachments)
1199+ _running.value = false
1200+ return@launch
1201+ }
1202+ runCatchingCancellable {
1203+ conn.repository.sendPrompt(
1204+ sessionId,
1205+ trimmed,
1206+ attachments = attachments.map { it.part },
1207+ model = _selectedModel.value?.ref,
1208+ agent = _selectedAgent.value,
1209+ idempotencyKey = key,
1210+ )
1211+ }.onFailure {
1212+ // Mark the optimistic message as failed so the user sees a failure indicator
1213+ // instead of a perpetual "sending" state.
1214+ if (optimisticId != null) {
1215+ _optimisticMessages.update { entries ->
1216+ entries.map { if (it.tempId == optimisticId) it.copy(failed = true) else it }
1217+ }
1218+ }
1219+ suppressDraftPersist.set(false)
1220+ _failedDraft.value = trimmed
1221+ failedIdempotencyKey = key
1222+ // Only restore the draft if the user hasn't typed anything new since.
1223+ if (_draft.value.isBlank()) updateDraft(trimmed)
1224+ // Restore the staged attachments too, so a failed send doesn't lose them —
1225+ // but only if the user hasn't staged new ones in the meantime.
1226+ if (attachments.isNotEmpty() && _attachments.value.isEmpty()) _attachments.value = attachments
1227+ // Re-persist so the restored attachments survive process death after a failure.
1228+ persistAttachments()
1229+ // Retryable: this is the failed send whose prompt Retry re-submits.
1230+ _errorEvents.trySend(ChatError(container.friendlyError(it), retryable = true))
1231+ _running.value = false
1232+ }.onSuccess {
1233+ suppressDraftPersist.set(false)
1234+ // This attempt reached the server, so the stashed retry key is spent — drop it so a
1235+ // later unrelated send can't reuse it (retryFailed clears it on success too).
1236+ failedIdempotencyKey = null
1237+ // Send succeeded — the attachments rode along, so clear their persisted copy
1238+ // (or keep any the user staged after the optimistic clear).
1239+ persistAttachments()
1240+ // Send succeeded — persist the cleared draft, but only if the user hasn't
1241+ // typed a new one while the send was in flight. Otherwise clearing the
1242+ // store echoes back through the draft observer and wipes the in-progress
1243+ // text (data loss). Mirrors the guard on the failure path above.
1244+ if (_draft.value.isBlank()) {
1245+ lastPersistedDraft = ""
1246+ container.draftStore.set(sessionId, "")
1247+ }
1248+ // Don't reset _running here: the agent continues streaming via SSE.
1249+ // _running is cleared on SessionIdle/SessionError (see event collector)
1250+ // or when the SSE stream drops (see connection state watcher below).
1251+ }
1252+ }
1253+ return true
1254+ }
1255+
1256+ /**
1257+ * Queue [trimmed] (+ [attachments]) to the offline outbox, clearing the composer
1258+ * optimistically like a real send. AppContainer flushes the outbox automatically on
1259+ * reconnect. The prompt is attributed to the active (or most-recent) server profile so a
1260+ * reconnect to a different server doesn't misfire it. On any failure the draft/attachments
1261+ * are restored so nothing is lost.
1262+ */
1263+ private fun enqueueOffline(trimmed: String, attachments: List<PendingAttachment>): Boolean {
1264+ _failedDraft.value = null
1265+ if (attachments.isNotEmpty()) _attachments.value = emptyList()
1266+ val clearDraft = _draft.value.trim() == trimmed
1267+ if (clearDraft) {
1268+ suppressDraftPersist.set(true)
1269+ _draft.value = ""
1270+ }
1271+ viewModelScope.launch {
1272+ val profileId = connection?.profile?.id
1273+ ?: runCatchingCancellable { container.profileStore.profiles.first().firstOrNull()?.id }.getOrNull()
1274+ if (profileId == null) {
1275+ // No server to attribute the message to — restore the composer and report it
1276+ // rather than silently dropping the prompt.
1277+ suppressDraftPersist.set(false)
1278+ if (_draft.value.isBlank()) updateDraft(trimmed)
1279+ if (attachments.isNotEmpty() && _attachments.value.isEmpty()) _attachments.value = attachments
1280+ persistAttachments()
1281+ _errorEvents.trySend(ChatError(container.string(R.string.no_servers_to_reconnect)))
1282+ return@launch
1283+ }
1284+ runCatchingCancellable {
1285+ container.outboxStore.enqueue(
1286+ OutboxMessage(
1287+ id = java.util.UUID.randomUUID().toString(),
1288+ profileId = profileId,
1289+ sessionId = sessionId,
1290+ text = trimmed,
1291+ attachments = attachments.map { it.toPersisted() },
1292+ model = _selectedModel.value?.ref,
1293+ agent = _selectedAgent.value,
1294+ createdAt = System.currentTimeMillis(),
1295+ ),
1296+ )
1297+ }.onSuccess {
1298+ suppressDraftPersist.set(false)
1299+ // The staged attachments now live in the outbox item; clear their draft copy.
1300+ persistAttachments()
1301+ if (_draft.value.isBlank()) {
1302+ lastPersistedDraft = ""
1303+ container.draftStore.set(sessionId, "")
1304+ }
1305+ // Nudge a flush in case we're actually online with a live connection.
1306+ container.flushOutbox()
1307+ _errorEvents.trySend(ChatError(container.string(R.string.outbox_queued)))
1308+ }.onFailure {
1309+ suppressDraftPersist.set(false)
1310+ if (_draft.value.isBlank()) updateDraft(trimmed)
1311+ if (attachments.isNotEmpty() && _attachments.value.isEmpty()) _attachments.value = attachments
1312+ persistAttachments()
1313+ _errorEvents.trySend(ChatError(container.friendlyError(it)))
1314+ }
1315+ }
1316+ return true
1317+ }
1318+
1319+ /** Discard a single queued (offline) message. */
1320+ fun discardQueued(id: String) {
1321+ viewModelScope.launch { runCatchingCancellable { container.outboxStore.remove(id) } }
1322+ }
1323+
1324+ /** Discard every queued (offline) message for this session. */
1325+ fun discardAllQueued() {
1326+ viewModelScope.launch { runCatchingCancellable { container.outboxStore.removeForSession(sessionId) } }
1327+ }
1328+
1329+ /** Attempt to flush the outbox now (e.g. the user tapped "Send now"). */
1330+ fun flushQueued() { container.flushOutbox() }
1331+
1332+ /** Re-send the last draft whose send failed, if any. */
1333+ fun retryFailed() {
1334+ val draft = _failedDraft.value ?: return
1335+ // Reuse the failed send's idempotency key so a retry after a lost response is
1336+ // deduplicated server-side rather than starting a duplicate run.
1337+ val key = failedIdempotencyKey
1338+ // Don't clear _failedDraft until send() accepts the text — if _running is
1339+ // already true, send() returns false and the draft would be lost forever.
1340+ // Attachments aren't re-sent on retry (they were optimistically restored to the
1341+ // composer on the original failure, so they'll ride the next manual send).
1342+ // send() accepts the text synchronously, but its onSuccess/onFailure callbacks run
1343+ // asynchronously. Clearing failedIdempotencyKey here would open a window between this
1344+ // synchronous clear and the async onFailure (which re-stashes the key) during which a
1345+ // manual re-send of the same text gets a fresh UUID — defeating server-side deduplication.
1346+ // Let send()'s own callbacks own the key's lifecycle, as they do for non-retry paths.
1347+ if (send(draft, includeAttachments = false, idempotencyKey = key)) {
1348+ _failedDraft.value = null
1349+ }
1350+ }
1351+
1352+ /** Dismiss a single failed optimistic message by its tempId so it no longer lingers in the
1353+ * list. Without this there's no recovery path for a failed message the user chose to abandon
1354+ * (its text won't ever match a real server message, so reconcileOptimistic never clears it). */
1355+ fun dismissOptimistic(tempId: String) {
1356+ _optimisticMessages.update { entries -> entries.filterNot { it.tempId == tempId } }
1357+ }
1358+
1359+ /** Re-send a specific failed optimistic message by its tempId. Loads its text into the
1360+ * composer and fires a send, mirroring the snackbar's Retry path. Returns false (and is a
1361+ * no-op) if the entry can't be found or a run is already active. */
1362+ fun retryOptimisticMessage(tempId: String): Boolean {
1363+ val entry = _optimisticMessages.value.firstOrNull { it.tempId == tempId && it.failed } ?: return false
1364+ return send(entry.text, includeAttachments = false)
1365+ }
1366+
1367+ /**
1368+ * Queue [text] to be sent automatically when the current run finishes, or clear any
1369+ * queued follow-up when [text] is blank. Used when the user taps Send while a run
1370+ * is already active — the Send button is replaced by Stop during a run, but the
1371+ * input field stays enabled, so a follow-up typed mid-run would otherwise have
1372+ * nowhere to go.
1373+ */
1374+ fun queueFollowUp(text: String) {
1375+ val trimmed = text.trim()
1376+ setQueuedFollowUp(trimmed.takeIf { it.isNotEmpty() })
1377+ // The text now lives in the queued chip, so clear the input field just as a
1378+ // normal send would. No-op for the cancel case (blank text), which must leave
1379+ // whatever the user has since typed untouched.
1380+ if (trimmed.isNotEmpty()) {
1381+ _draft.value = ""
1382+ // Persist the cleared draft IMMEDIATELY, not just in memory: the follow-up was already
1383+ // persisted synchronously above, so if the process is killed before the debounced
1384+ // draft-persist fires, the same text would remain under BOTH the draft and follow-up
1385+ // keys — and cold-start recovery would restore it to the composer AND re-queue it (a
1386+ // duplicate). Mirror send()'s lastPersistedDraft update so the drafts observer treats
1387+ // this store change as our own echo and doesn't write the cleared value back.
1388+ lastPersistedDraft = ""
1389+ container.draftStore.flushDraft(sessionId, "")
1390+ }
1391+ }
1392+
1393+ /** Set or clear the queued follow-up and mirror it to persistence, so a follow-up
1394+ * queued mid-run survives process death (recovered on the next open of this session).
1395+ * Passing null / blank clears both the in-memory value and the persisted copy. */
1396+ private fun setQueuedFollowUp(text: String?) {
1397+ _queuedFollowUp.value = text
1398+ container.draftStore.flushFollowUp(sessionId, text.orEmpty())
1399+ }
1400+
1401+ /** Transient flag set by [refreshMessages] so the top-bar refresh icon can show
1402+ * a brief spinner as immediate tap feedback. The SSE reconnect triggered by
1403+ * refreshMessages may not visibly change [connectionState] (it's already
1404+ * Connected), so without this the tap appears to do nothing. Clears after a
1405+ * short delay or when the connection state next becomes Connected. */
1406+ private val _refreshing = MutableStateFlow(false)
1407+ val refreshing: StateFlow<Boolean> = _refreshing.asStateFlow()
1408+
1409+ /** Manually re-fetch messages by forcing the SSE stream to reconnect, which
1410+ * triggers the repository's re-seed-from-REST logic. Gives the user a recovery
1411+ * path when the SSE stream silently drops and the auto-reconnect re-seed is slow
1412+ * or fails — a pull-to-refresh or top-bar refresh forces a fresh fetch under the
1413+ * existing generation-guarded merge. Also clears [loadError] optimistically and
1414+ * sets [refreshing] for immediate tap feedback. */
1415+ fun refreshMessages() {
1416+ val conn = connection ?: return
1417+ _loadError.value = false
1418+ _loadErrorInline.value = false
1419+ _refreshing.value = true
1420+ conn.events.triggerReconnect()
1421+ viewModelScope.launch {
1422+ delay(NetworkConfig.refreshFeedbackMs)
1423+ _refreshing.value = false
1424+ }
1425+ }
1426+
1427+ /** Invoke a slash-command by name via the server's /command endpoint. */
1428+ fun runCommand(command: Command) {
1429+ val conn = connection ?: return
1430+ // A run is already in flight — surface feedback instead of silently dropping the
1431+ // command (the picker stays openable during a run and dismisses on select).
1432+ if (!beginRun()) {
1433+ _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1434+ return
1435+ }
1436+ // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1437+ // reconnect keeps the working indicator lit instead of leaving it stuck off.
1438+ runEndedByIdle = false
1439+ viewModelScope.launch {
1440+ runCatchingCancellable {
1441+ conn.repository.runCommand(sessionId, command.name, agent = command.agent)
1442+ }.onFailure {
1443+ _errorEvents.trySend(ChatError(container.friendlyError(it)))
1444+ _running.value = false
1445+ }
1446+ }
1447+ }
1448+
1449+ fun abort() {
1450+ val conn = connection ?: return
1451+ // Drop any queued follow-up so the SessionIdle that follows the abort doesn't
1452+ // auto-send it — the user tapped Stop to halt work, not to trigger the next turn.
1453+ setQueuedFollowUp(null)
1454+ if (!_aborting.compareAndSet(false, true)) return
1455+ viewModelScope.launch {
1456+ try {
1457+ // Run the abort POST under NonCancellable so it survives this scope being
1458+ // cancelled. The "Stop and exit" path calls abort() then navigates back
1459+ // immediately, which pops this ViewModel's back-stack entry → onCleared() →
1460+ // viewModelScope.cancel(); without this the in-flight abort is cancelled (often
1461+ // before the request even reaches the server) and the run keeps burning tokens
1462+ // server-side despite the user explicitly stopping it.
1463+ runCatchingCancellable { withContext(NonCancellable) { conn.repository.abort(sessionId) } }
1464+ .onSuccess {
1465+ _running.value = false
1466+ // Close the re-light gate so trailing SSE events in flight between the
1467+ // abort POST's ack and the server's SessionIdle don't re-light the run
1468+ // indicator (and re-arm the FGS / keepScreenOn) after the user hit Stop.
1469+ // send()/runCommand()/continueRun() all open this gate on beginRun();
1470+ // abort() is the one run-ending path that previously missed it.
1471+ runEndedByIdle = true
1472+ // Surface an undo opportunity so an accidental Stop is recoverable.
1473+ // The UI shows a "Stopped — Undo" snackbar; tapping Undo re-sends the
1474+ // last prompt. Only when there's a tracked prompt to undo back to.
1475+ val prompt = lastSentPrompt
1476+ if (prompt != null) _stopUndoEvents.trySend(prompt)
1477+ }
1478+ .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1479+ } finally {
1480+ _aborting.value = false
1481+ }
1482+ }
1483+ }
1484+
1485+ /** Re-send the most recently-sent user prompt, used by the Stop-undo snackbar so an
1486+ * accidental Stop can be recovered. No-op if no prompt is tracked (e.g. the run was
1487+ * started by a command, not a text prompt). Clears the tracked prompt so a second Undo
1488+ * tap doesn't double-send. */
1489+ fun resendLastPrompt() {
1490+ val prompt = lastSentPrompt ?: return
1491+ lastSentPrompt = null
1492+ send(prompt, includeAttachments = false)
1493+ }
1494+
1495+ fun addAttachment(attachment: PendingAttachment) {
1496+ // Enforce the cumulative size cap across the whole staged set (the per-file size and the
1497+ // count caps are checked upstream). The base64 data URLs are held in memory and
1498+ // re-serialized on every add/remove and again when the request body is built, so an
1499+ // unbounded set OOMs. Reject rather than add, mirroring the other attachment limits'
1500+ // snackbar via errorEvents. Size is the raw (pre-base64) byte count, matching the
1501+ // per-file check, and includes attachments already staged (incl. restored ones).
1502+ val staged = _attachments.value.sumOf { base64DataUrlByteSize(it.part.url) }
1503+ if (staged + base64DataUrlByteSize(attachment.part.url) > NetworkConfig.maxTotalAttachmentBytes) {
1504+ _errorEvents.trySend(ChatError(container.string(R.string.attachment_limit)))
1505+ return
1506+ }
1507+ _attachments.update { it + attachment }
1508+ persistAttachments()
1509+ }
1510+
1511+ fun removeAttachment(id: String) {
1512+ _attachments.update { list -> list.filterNot { it.id == id } }
1513+ persistAttachments()
1514+ }
1515+
1516+ fun clearAttachments() {
1517+ _attachments.value = emptyList()
1518+ persistAttachments()
1519+ }
1520+
1521+ /** Convert each picked Uri to a base64 attachment off the main thread, honoring the
1522+ * per-prompt count cap and surfacing per-file errors via [errorEvents] without aborting
1523+ * the batch. Runs on [viewModelScope] (which survives configuration changes) so an
1524+ * in-flight encode isn't cancelled and its progress counters ([stagingCount] /
1525+ * [stagingFileTotal]) aren't reset to 0 on rotation — the prior UI-side
1526+ * `rememberCoroutineScope` implementation lost both the work and the counters on
1527+ * every config change. */
1528+ fun stageUris(uris: List<android.net.Uri>, context: android.content.Context) {
1529+ if (uris.isEmpty()) return
1530+ val appContext = context.applicationContext
1531+ viewModelScope.launch {
1532+ _stagingCount.value++
1533+ _stagingFileTotal.value += uris.size
1534+ try {
1535+ uris.forEachIndexed { index, uri ->
1536+ if (_attachments.value.size >= NetworkConfig.maxAttachments) {
1537+ _errorEvents.trySend(ChatError(container.string(R.string.attachment_limit)))
1538+ // The cap dropped the rest of this batch; account for the files we
1539+ // won't process so stagingFileTotal doesn't linger over-counted,
1540+ // then abandon the batch. return@launch (not return@forEachIndexed)
1541+ // so the remaining uris aren't re-checked against a never-relieved
1542+ // cap (which would over-decrement the counter and re-fire the
1543+ // snackbar once per leftover uri). The `finally` below still
1544+ // decrements stagingCount.
1545+ _stagingFileTotal.value -= uris.size - index
1546+ return@launch
1547+ }
1548+ when (val result = uri.toAttachmentResult(appContext)) {
1549+ is AttachmentResult.Ok -> addAttachment(result.attachment)
1550+ AttachmentResult.TooLarge -> _errorEvents.trySend(ChatError(container.string(R.string.attachment_too_large)))
1551+ AttachmentResult.Failed -> _errorEvents.trySend(ChatError(container.string(R.string.attachment_failed)))
1552+ }
1553+ _stagingFileTotal.value--
1554+ }
1555+ } finally {
1556+ _stagingCount.value--
1557+ }
1558+ }
1559+ }
1560+
1561+ /** Persist the current staged attachments so an interrupted compose survives process
1562+ * death (mirrors draft persistence). Fire-and-forget on [viewModelScope]. */
1563+ private fun persistAttachments() {
1564+ val snapshot = _attachments.value
1565+ viewModelScope.launch {
1566+ runCatchingCancellable { container.attachmentDraftStore.save(sessionId, snapshot.map { it.toPersisted() }) }
1567+ .onFailure { Log.w("ChatViewModel", "Failed to persist attachments", it) }
1568+ }
1569+ }
1570+
1571+ /** Revert the conversation to just before [messageId], hiding everything after it. The
1572+ * authoritative state also arrives via SessionUpdated; we flag it here for immediate
1573+ * feedback. */
1574+ fun revertTo(messageId: String) {
1575+ val conn = connection ?: return
1576+ viewModelScope.launch {
1577+ runCatchingCancellable { conn.api.revert(sessionId, messageId) }
1578+ .onSuccess {
1579+ _reverted.value = it.isReverted
1580+ _revertDiff.value = it.revert?.diff?.takeIf { diff -> diff.isNotBlank() }
1581+ _editing.value = false
1582+ }
1583+ .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1584+ }
1585+ }
1586+
1587+ /**
1588+ * Regenerate an assistant reply: revert the conversation to that assistant message (removing
1589+ * it and everything after), then re-send the user prompt that immediately preceded it. A
1590+ * one-tap "try again" mirroring ChatGPT/Claude, instead of the two-step revert + re-Send.
1591+ *
1592+ * Captures the preceding user text *before* the revert (the messages list updates
1593+ * asynchronously via SSE once the revert lands). No-op if there's no preceding user prompt
1594+ * or the assistant message isn't found.
1595+ */
1596+ fun regenerate(assistantMessageId: String) {
1597+ val conn = connection ?: return
1598+ val msgs = messages.value
1599+ val idx = msgs.indexOfFirst { it.info.id == assistantMessageId }
1600+ if (idx < 0) return
1601+ val precedingUserText = (idx - 1 downTo 0)
1602+ .asSequence()
1603+ .map { msgs[it] }
1604+ .firstOrNull { it.info is UserMessage }
1605+ ?.parts
1606+ ?.filterIsInstance<TextPart>()
1607+ ?.joinToString("\n\n") { it.text }
1608+ ?.takeIf { it.isNotBlank() }
1609+ ?: return
1610+ viewModelScope.launch {
1611+ runCatchingCancellable { conn.api.revert(sessionId, assistantMessageId) }
1612+ .onSuccess {
1613+ // send() can return false if the beginRun() CAS lost to a concurrent run
1614+ // or the message had to be enqueued offline and that failed. The revert has
1615+ // already hidden the messages, so a silent false would leave the user with a
1616+ // reverted conversation and no reply and no error — surface it instead.
1617+ val sent = send(precedingUserText, includeAttachments = false)
1618+ if (!sent) {
1619+ _errorEvents.trySend(ChatError(container.string(R.string.error_generic)))
1620+ }
1621+ }
1622+ .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1623+ }
1624+ }
1625+
1626+ /** Continue a partially-generated assistant reply by sending a "continue" prompt
1627+ * without reverting (unlike [regenerate], which re-runs from the preceding user
1628+ * prompt and loses the partial). The partial reply stays as context so the model
1629+ * can pick up where it left off. This is the standard chat-app "continue" pattern
1630+ * for a reply that was aborted mid-stream. */
1631+ fun continueRun(@Suppress("UNUSED_PARAMETER") assistantMessageId: String) {
1632+ val conn = connection ?: return
1633+ if (running.value || aborting.value) return
1634+ if (!beginRun()) return
1635+ // Reopen the relight gate so this run's streamed parts keep the indicator lit (and a
1636+ // later SessionIdle re-closes it). Without this, runEndedByIdle stays true from the
1637+ // previous run's SessionIdle and the relight guard at the event collector would suppress
1638+ // every streamed part of the continuation, leaving the working indicator / Stop button /
1639+ // foreground service off for the whole run.
1640+ runEndedByIdle = false
1641+ viewModelScope.launch {
1642+ // Route through the repository (not conn.api directly) so the prompt inherits
1643+ // withRetry (transient-failure backoff) and the user's selected model/agent — a
1644+ // direct api.sendPrompt would neither retry nor carry the picker selection, and
1645+ // would mint a fresh idempotency key on every retry attempt instead of sharing one.
1646+ runCatchingCancellable {
1647+ conn.repository.sendPrompt(
1648+ sessionId,
1649+ text = "continue",
1650+ model = _selectedModel.value?.ref,
1651+ agent = _selectedAgent.value,
1652+ )
1653+ }.onFailure {
1654+ _running.value = false
1655+ _errorEvents.trySend(ChatError(container.friendlyError(it)))
1656+ }
1657+ }
1658+ }
1659+
1660+ /** Load a previously-sent user prompt back into the composer to edit and re-send.
1661+ * Reverts the conversation to just before [messageId] (hiding it and everything after)
1662+ * and prefills the draft with [text]. The next Send continues from that checkpoint,
1663+ * effectively replacing the edited message; the reverted banner's Undo restores it.
1664+ * Overwrites the current draft — this is an explicit edit action on that message. */
1665+ fun editMessage(messageId: String, text: String) {
1666+ val conn = connection ?: return
1667+ _draft.value = text.take(NetworkConfig.maxDraftLengthChars)
1668+ _pendingQuote.value = null
1669+ // Do NOT delegate to revertTo(): revertTo's onSuccess sets _editing = false, which races
1670+ // this synchronous _editing = true (the async revert completes after editMessage returns
1671+ // and clobbers the banner). Inline the revert so editMessage owns the _editing lifecycle:
1672+ // set it true on success, matching the documented contract ("Cleared on unrevert and on
1673+ // send"). revertTo() (the manual revert button) still clears _editing itself.
1674+ viewModelScope.launch {
1675+ runCatchingCancellable { conn.api.revert(sessionId, messageId) }
1676+ .onSuccess {
1677+ _reverted.value = it.isReverted
1678+ _revertDiff.value = it.revert?.diff?.takeIf { diff -> diff.isNotBlank() }
1679+ _editing.value = true
1680+ }
1681+ .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1682+ }
1683+ }
1684+
1685+ /** Prefill the composer with [text] as a Markdown blockquote so the user can respond to a
1686+ * specific message inline. Appends to any existing draft (with a blank line) rather than
1687+ * overwriting, so quoting doesn't discard text already being typed. Also stashes the raw
1688+ * quoted text in [pendingQuote] so the composer can show a dismissible preview card. */
1689+ fun quoteReply(text: String) {
1690+ val quoted = text.trim().lineSequence().joinToString("\n") { "> $it" }
1691+ if (quoted.isBlank()) return
1692+ val current = _draft.value
1693+ val combined = if (current.isBlank()) "$quoted\n\n" else "$current\n\n$quoted\n\n"
1694+ // Cap like the composer's typed/pasted/dictated input so quoting onto a near-full draft
1695+ // can't push it past the limit (negative remaining counter + an over-cap prompt sent).
1696+ _draft.value = combined.take(NetworkConfig.maxDraftLengthChars)
1697+ _pendingQuote.value = text.trim().takeIf { it.isNotBlank() }
1698+ }
1699+
1700+ /** Remove the pending quote-reply preview and strip the `>`-prefixed blockquote lines from
1701+ * the draft so canceling the quote card actually removes the quoted text, not just the chip.
1702+ * Strips by leading `> ` prefix (structural match) rather than by exact full-text replace,
1703+ * so a user who edited any quoted line is still cleaned up — the exact-text replaces would
1704+ * silently no-op on an edit, leaving orphan `>` lines in the draft after the chip vanished. */
1705+ fun cancelQuoteReply() {
1706+ val removed = _pendingQuote.value ?: return
1707+ _pendingQuote.value = null
1708+ val quoteLineCount = removed.trim().lineSequence().count()
1709+ // Walk the draft and drop the first contiguous run of `> `-prefixed lines that matches
1710+ // the stashed quote's line count (the block quoteReply inserted). Lines the user edited
1711+ // still begin with `> ` (quoteReply prefixed every line), so a prefix match finds them
1712+ // even when their trailing content diverges from the stashed original.
1713+ val lines = _draft.value.split("\n").toMutableList()
1714+ var runStart = -1
1715+ var runLen = 0
1716+ var i = 0
1717+ while (i < lines.size) {
1718+ if (lines[i].startsWith("> ") || lines[i] == ">") {
1719+ if (runStart < 0) runStart = i
1720+ runLen++
1721+ if (runLen == quoteLineCount) {
1722+ // Strip the run plus a single trailing blank line that separated the quote
1723+ // from the reply (quoteReply appends "\n\n" after the block).
1724+ val stripUntil = i + 1
1725+ if (stripUntil < lines.size && lines[stripUntil].isEmpty()) {
1726+ lines.subList(runStart, stripUntil + 1).clear()
1727+ } else {
1728+ lines.subList(runStart, stripUntil).clear()
1729+ }
1730+ break
1731+ }
1732+ i++
1733+ } else {
1734+ runStart = -1
1735+ runLen = 0
1736+ i++
1737+ }
1738+ }
1739+ _draft.value = lines.joinToString("\n").trimStart()
1740+ }
1741+
1742+ /** Fork the conversation by creating a brand-new session seeded with [text] as its first
1743+ * prompt (using the currently-selected model/agent). Emits the new session id via
1744+ * [branchEvents] so the UI can navigate to it. There's no server-side "branch" endpoint,
1745+ * so this starts a fresh session from the chosen prompt rather than copying history. */
1746+ fun branchFrom(text: String) {
1747+ val conn = connection ?: return
1748+ val prompt = text.trim()
1749+ if (prompt.isEmpty()) return
1750+ viewModelScope.launch {
1751+ var createdId: String? = null
1752+ runCatchingCancellable {
1753+ val session = conn.repository.createSession(title = null)
1754+ createdId = session.id
1755+ conn.repository.sendPrompt(
1756+ session.id,
1757+ prompt,
1758+ model = _selectedModel.value?.ref,
1759+ agent = _selectedAgent.value,
1760+ )
1761+ session.id
1762+ }
1763+ .onSuccess { newId -> _branchEvents.trySend(newId) }
1764+ .onFailure {
1765+ // If the session was created but the prompt failed, delete the orphaned
1766+ // empty session so failed branches don't accumulate on the server.
1767+ val id = createdId
1768+ if (id != null) {
1769+ runCatchingCancellable { conn.repository.deleteSession(id) }
1770+ }
1771+ _errorEvents.trySend(ChatError(container.string(R.string.branch_failed)))
1772+ }
1773+ }
1774+ }
1775+
1776+ /** Undo the active revert checkpoint, restoring the hidden messages. */
1777+ fun unrevert() {
1778+ val conn = connection ?: return
1779+ viewModelScope.launch {
1780+ runCatchingCancellable { conn.api.unrevert(sessionId) }
1781+ .onSuccess {
1782+ _reverted.value = it.isReverted
1783+ _revertDiff.value = it.revert?.diff?.takeIf { diff -> diff.isNotBlank() }
1784+ _editing.value = false
1785+ }
1786+ .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1787+ }
1788+ }
1789+
1790+ /** Create (or fetch the existing) public share link. The URL is exposed via [shareUrl]
1791+ * and also emitted once via [shareLinkEvents] so the UI can copy it to the clipboard. */
1792+ fun shareSession() {
1793+ val conn = connection ?: return
1794+ viewModelScope.launch {
1795+ runCatchingCancellable { conn.api.shareSession(sessionId) }
1796+ .onSuccess { session ->
1797+ val url = session.share?.url?.takeIf { it.isNotBlank() }
1798+ _shareUrl.value = url
1799+ if (url != null) _shareLinkEvents.trySend(url)
1800+ }
1801+ .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1802+ }
1803+ }
1804+
1805+ /** Revoke the session's public share link. */
1806+ fun unshareSession() {
1807+ val conn = connection ?: return
1808+ viewModelScope.launch {
1809+ runCatchingCancellable { conn.api.unshareSession(sessionId) }
1810+ .onSuccess { _shareUrl.value = it.share?.url?.takeIf { it.isNotBlank() } }
1811+ .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1812+ }
1813+ }
1814+
1815+ /** Compact the conversation via the summarize endpoint; the summary streams back via SSE.
1816+ * Uses the currently-selected model (required by the endpoint). */
1817+ fun summarize() {
1818+ val conn = connection ?: return
1819+ val model = _selectedModel.value?.ref ?: run {
1820+ _errorEvents.trySend(ChatError(container.string(R.string.needs_model)))
1821+ return
1822+ }
1823+ if (!beginRun()) {
1824+ _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1825+ return
1826+ }
1827+ // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1828+ // reconnect keeps the working indicator lit instead of leaving it stuck off.
1829+ runEndedByIdle = false
1830+ viewModelScope.launch {
1831+ runCatchingCancellable { conn.api.summarize(sessionId, model) }
1832+ .onFailure {
1833+ _errorEvents.trySend(ChatError(container.friendlyError(it)))
1834+ _running.value = false
1835+ }
1836+ }
1837+ }
1838+
1839+ /** Analyze the project and (re)generate its AGENTS.md; the run streams back via SSE. */
1840+ fun initProject() {
1841+ val conn = connection ?: return
1842+ if (!beginRun()) {
1843+ _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1844+ return
1845+ }
1846+ // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1847+ // reconnect keeps the working indicator lit instead of leaving it stuck off.
1848+ runEndedByIdle = false
1849+ viewModelScope.launch {
1850+ runCatchingCancellable { conn.api.initSession(sessionId) }
1851+ .onFailure {
1852+ _errorEvents.trySend(ChatError(container.friendlyError(it)))
1853+ _running.value = false
1854+ }
1855+ }
1856+ }
1857+
1858+ /** Run a one-off shell [command] in the session's worktree; output streams back via SSE.
1859+ * The server requires an agent to scope the run — use the selected one, else the primary. */
1860+ fun runShell(command: String) {
1861+ val conn = connection ?: return
1862+ val cmd = command.trim()
1863+ if (cmd.isEmpty()) return
1864+ val agent = _selectedAgent.value
1865+ ?: _agents.value.firstOrNull { it.isPrimary }?.name
1866+ ?: _agents.value.firstOrNull()?.name
1867+ ?: DEFAULT_SHELL_AGENT
1868+ if (!beginRun()) {
1869+ _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1870+ return
1871+ }
1872+ // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1873+ // reconnect keeps the working indicator lit instead of leaving it stuck off.
1874+ runEndedByIdle = false
1875+ viewModelScope.launch {
1876+ runCatchingCancellable { conn.api.shell(sessionId, cmd, agent, _selectedModel.value?.ref) }
1877+ .onFailure {
1878+ _errorEvents.trySend(ChatError(container.friendlyError(it)))
1879+ _running.value = false
1880+ }
1881+ }
1882+ }
1883+
1884+ /** Rename the current session via PATCH /session/:id. On success updates [sessionTitle]
1885+ * so the top bar reflects the new name immediately. A failure surfaces as a snackbar;
1886+ * the caller keeps the dialog open so the user can retry without retyping. */
1887+ fun renameSession(newTitle: String) {
1888+ val conn = connection ?: return
1889+ val title = newTitle.trim()
1890+ if (title.isEmpty() || title == _sessionTitle.value) return
1891+ viewModelScope.launch {
1892+ runCatchingCancellable { conn.api.updateSession(sessionId, title) }
1893+ .onSuccess { _sessionTitle.value = it.displayTitle }
1894+ .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1895+ }
1896+ }
1897+
1898+ /** Delete the current session via DELETE /session/:id. Schedules a deferred delete with an
1899+ * undo window and signals [deleteUndoEvents] so the ChatScreen shows an Undo snackbar
1900+ * *over the conversation the user is viewing* — they can cancel without first being
1901+ * navigated back to the session list. The actual REST delete runs after
1902+ * [NetworkConfig.undoDeleteDelayMs]; cancel via [cancelSessionDelete]. If the window
1903+ * expires, the scheduled delete's onDeleted sets [sessionDeleted] so the UI navigates away.
1904+ * A failure surfaces as a snackbar. */
1905+ fun deleteSession() {
1906+ val conn = connection ?: return
1907+ container.scheduleSessionDelete(
1908+ id = sessionId,
1909+ delayMs = NetworkConfig.undoDeleteDelayMs,
1910+ // Navigate away only once the deferred delete actually commits — so an in-place Undo
1911+ // before the window expires keeps the user on this conversation.
1912+ onDeleted = { _sessionDeleted.value = true },
1913+ onError = { _errorEvents.trySend(ChatError(container.friendlyError(it))) },
1914+ )
1915+ _deleteUndoEvents.tryEmit(sessionId)
1916+ }
1917+
1918+ /** Cancel a delete scheduled by [deleteSession] (the in-chat Undo action). Returns true if the
1919+ * delete was still pending (Undo succeeded). */
1920+ fun cancelSessionDelete(): Boolean = container.cancelSessionDelete(sessionId)
1921+
1922+ /** Cancel a delete scheduled for [id] — the `collectLatest` undo path in ChatScreen calls
1923+ * this with the tracked `pending` id when a newer delete supersedes an in-flight one, so
1924+ * the prior session's deferred REST delete is withdrawn (no-op if it already fired)
1925+ * instead of being left to fire with no reachable Undo button. Mirrors
1926+ * [soy.iko.opencode.ui.session.SessionListViewModel.undoDelete]. Returns false when [id]
1927+ * isn't this VM's session (defensive — a ChatScreen only ever deletes its own session). */
1928+ fun cancelSessionDelete(id: String): Boolean {
1929+ if (id != sessionId) return false
1930+ return container.cancelSessionDelete(id)
1931+ }
1932+
1933+ /** Reconnect to the most recently used server profile (used when the connection is gone). */
1934+ fun reconnect() {
1935+ if (container.activeConnection.value != null) return
1936+ // Guard against concurrent reconnect calls: the user can tap the button
1937+ // multiple times while _reconnecting is true, which would launch parallel
1938+ // connect() coroutines that race to set the active connection, leaking the
1939+ // intermediate connections.
1940+ if (!_reconnecting.compareAndSet(false, true)) return
1941+ viewModelScope.launch {
1942+ try {
1943+ val recent = runCatchingCancellable { container.profileStore.profiles.first() }
1944+ .getOrDefault(emptyList())
1945+ .firstOrNull()
1946+ if (recent == null) {
1947+ _errorEvents.trySend(ChatError(container.string(R.string.no_servers_to_reconnect)))
1948+ return@launch
1949+ }
1950+ runCatchingCancellable {
1951+ val conn = container.connect(recent)
1952+ conn.api.ping()
1953+ }.onFailure {
1954+ container.disconnect()
1955+ _errorEvents.trySend(ChatError(container.friendlyError(it)))
1956+ }
1957+ } finally {
1958+ _reconnecting.value = false
1959+ }
1960+ }
1961+ }
1962+
1963+ fun respondPermission(permission: Permission, response: PermissionResponse) {
1964+ val conn = connection ?: return
1965+ // Record a session-scoped grant so subsequent matching requests are auto-answered.
1966+ // SESSION maps to ONCE on the wire (the server has no session-scope concept); the
1967+ // client-side set is what gives it the "for this session" semantics.
1968+ if (response == PermissionResponse.SESSION) {
1969+ val type = permission.type.orEmpty()
1970+ val pattern = permission.patternText.orEmpty()
1971+ if (type.isNotEmpty()) sessionAllowed.add(type to pattern)
1972+ }
1973+ // Dismiss optimistically (revealing any queued request); permission.replied will confirm.
1974+ // If the call fails we re-surface this request so the user can retry instead of being stuck
1975+ // with a dismissed dialog and a tool run that's still paused server-side.
1976+ resolvePermission(permission.id)
1977+ viewModelScope.launch {
1978+ runCatchingCancellable { conn.api.respondPermission(sessionId, permission.id, response) }
1979+ .onFailure {
1980+ _errorEvents.trySend(ChatError(container.friendlyError(it)))
1981+ // Revoke the session-scoped grant on failure: enqueuePermission() auto-responds
1982+ // when (type, pattern) is in sessionAllowed, so leaving the entry in would loop
1983+ // respondPermission -> fail -> enqueuePermission -> respondPermission ... forever,
1984+ // spamming the dead server and the error snackbar without ever re-prompting the
1985+ // user. Removing it makes enqueuePermission fall through to re-queueing the
1986+ // request for the dialog so the user can retry.
1987+ //
1988+ // This must cover BOTH the SESSION and ONCE response paths: enqueuePermission's
1989+ // auto-respond sends ONCE, not SESSION, so guarding only the SESSION branch (as
1990+ // the original code did) leaves the entry in and still loops when the auto-reply
1991+ // fails. Revoke unconditionally on any failure.
1992+ val type = permission.type.orEmpty()
1993+ val pattern = permission.patternText.orEmpty()
1994+ if (type.isNotEmpty()) sessionAllowed.remove(type to pattern)
1995+ enqueuePermission(permission)
1996+ }
1997+ }
1998+ }
1999+
2000+ private companion object {
2001+ /** Fallback agent for the shell endpoint when none is selected and no agent catalog
2002+ * loaded. opencode's default primary agent is "build". */
2003+ const val DEFAULT_SHELL_AGENT = "build"
2004+ }
2005+
2006+ override fun onCleared() {
2007+ super.onCleared()
2008+ // Flush any pending debounced draft so it survives navigation. Uses an
2009+ // asynchronous apply() (not a synchronous commit) so the main thread isn't
2010+ // blocked on disk I/O — Android's SharedPreferences framework guarantees
2011+ // pending apply() writes are flushed before the process exits.
2012+ // Flush the pending draft — including an empty one. Persistence is otherwise debounced,
2013+ // so if the user cleared the input and navigated back within the debounce window, the
2014+ // debounce coroutine was cancelled with viewModelScope and the prefs still hold the
2015+ // previous non-empty draft. flushDraft() removes the key when the text is blank, so
2016+ // flushing commits the clear instead of resurrecting the deleted text.
2017+ //
2018+ // BUT respect the send-in-flight guard: send() optimistically clears _draft to "" WITHOUT
2019+ // persisting the clear so a failed send can restore the text. If the user taps Send then
2020+ // immediately navigates back (cancelling the in-flight send), flushing the blank _draft here
2021+ // would remove the persisted key and erase the pre-send draft with no recovery. Skip the
2022+ // flush while a send is in flight so the pre-send draft stays persisted (recoverable).
2023+ if (!suppressDraftPersist.get()) container.draftStore.flushDraft(sessionId, _draft.value)
2024+ }
2025+}
app/src/main/java/soy/iko/opencode/ui/chat/CommandPickerSheet.ktnew file+248
@@ -0,0 +1,248 @@
1+package soy.iko.opencode.ui.chat
2+
3+import androidx.compose.foundation.clickable
4+import androidx.compose.foundation.layout.Column
5+import androidx.compose.foundation.layout.Row
6+import androidx.compose.foundation.layout.fillMaxWidth
7+import androidx.compose.foundation.layout.heightIn
8+import androidx.compose.foundation.layout.imePadding
9+import androidx.compose.foundation.layout.navigationBarsPadding
10+import androidx.compose.foundation.layout.padding
11+import androidx.compose.foundation.lazy.LazyColumn
12+import androidx.compose.foundation.lazy.items
13+import androidx.compose.foundation.text.KeyboardActions
14+import androidx.compose.foundation.text.KeyboardOptions
15+import androidx.compose.material.icons.Icons
16+import androidx.compose.material.icons.automirrored.filled.PlaylistPlay
17+import androidx.compose.material.icons.filled.Close
18+import androidx.compose.material.icons.filled.Search
19+import androidx.compose.material3.CircularProgressIndicator
20+import androidx.compose.material3.ExperimentalMaterial3Api
21+import androidx.compose.material3.Icon
22+import androidx.compose.material3.IconButton
23+import androidx.compose.material3.MaterialTheme
24+import androidx.compose.material3.ModalBottomSheet
25+import androidx.compose.material3.OutlinedTextField
26+import androidx.compose.material3.Text
27+import androidx.compose.material3.AlertDialog
28+import androidx.compose.material3.TextButton
29+import androidx.compose.material3.rememberModalBottomSheetState
30+import androidx.compose.runtime.Composable
31+import androidx.compose.runtime.LaunchedEffect
32+import androidx.compose.runtime.getValue
33+import androidx.compose.runtime.mutableStateOf
34+import androidx.compose.runtime.remember
35+import androidx.compose.runtime.rememberCoroutineScope
36+import androidx.compose.runtime.saveable.rememberSaveable
37+import androidx.compose.runtime.setValue
38+import androidx.compose.ui.Alignment
39+import androidx.compose.ui.Modifier
40+import androidx.compose.ui.focus.FocusRequester
41+import androidx.compose.ui.focus.focusRequester
42+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
43+import androidx.compose.ui.platform.LocalHapticFeedback
44+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
45+import androidx.compose.ui.res.stringResource
46+import androidx.compose.ui.semantics.Role
47+import androidx.compose.ui.semantics.contentDescription
48+import androidx.compose.ui.semantics.semantics
49+import androidx.compose.ui.semantics.stateDescription
50+import androidx.compose.ui.text.input.ImeAction
51+import androidx.compose.ui.text.style.TextOverflow
52+import androidx.compose.ui.unit.dp
53+import androidx.compose.foundation.layout.Box
54+import kotlinx.coroutines.launch
55+import soy.iko.opencode.data.model.Command
56+import soy.iko.opencode.data.network.NetworkConfig
57+import soy.iko.opencode.R
58+
59+/** Bottom sheet that lists slash-commands; selecting one invokes it. */
60+@OptIn(ExperimentalMaterial3Api::class)
61+@Composable
62+fun CommandPickerSheet(
63+ commands: List<Command>,
64+ loading: Boolean,
65+ error: Boolean,
66+ onSelect: (Command) -> Unit,
67+ onRetry: () -> Unit,
68+ onDismiss: () -> Unit,
69+) {
70+ // Persist only the command *name* (a savable String), not the Command itself: a
71+ // kotlinx @Serializable Command is neither Parcelable nor java.io.Serializable, so
72+ // routing it through rememberSaveable crashes on rotation while the confirm dialog
73+ // is open. Re-resolve the full Command from the current list for rendering.
74+ var pendingCommandName by rememberSaveable { mutableStateOf<String?>(null) }
75+ val pendingCommand = pendingCommandName?.let { name -> commands.firstOrNull { it.name == name } }
76+ // Hoist SheetState + skipPartiallyExpanded (see AgentPickerSheet for the full rationale).
77+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
78+ val scope = rememberCoroutineScope()
79+ ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
80+ Text(
81+ stringResource(R.string.commands),
82+ style = MaterialTheme.typography.titleMedium,
83+ modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
84+ )
85+ if (loading) {
86+ val loadingLabel = stringResource(R.string.loading_commands)
87+ Column(
88+ modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp).imePadding(),
89+ horizontalAlignment = Alignment.CenterHorizontally,
90+ ) {
91+ CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
92+ Text(
93+ stringResource(R.string.loading_commands),
94+ style = MaterialTheme.typography.bodyMedium,
95+ color = MaterialTheme.colorScheme.onSurfaceVariant,
96+ modifier = Modifier.padding(top = 8.dp),
97+ )
98+ }
99+ } else if (error && commands.isEmpty()) {
100+ Column(
101+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
102+ horizontalAlignment = Alignment.CenterHorizontally,
103+ ) {
104+ Text(
105+ stringResource(R.string.load_failed),
106+ style = MaterialTheme.typography.bodyMedium,
107+ color = MaterialTheme.colorScheme.error,
108+ )
109+ TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
110+ }
111+ } else if (commands.isEmpty()) {
112+ Text(
113+ stringResource(R.string.no_commands),
114+ style = MaterialTheme.typography.bodyMedium,
115+ color = MaterialTheme.colorScheme.onSurfaceVariant,
116+ modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
117+ )
118+ } else {
119+ var query by rememberSaveable { mutableStateOf("") }
120+ val keyboardController = LocalSoftwareKeyboardController.current
121+ val haptics = LocalHapticFeedback.current
122+ // Resolved once (not per-row): the semantics lambda below isn't a composable scope,
123+ // so stringResource can't be called inside it.
124+ val destructiveStateLabel = stringResource(R.string.command_confirmation_required)
125+ // Auto-focus the search field (and raise the keyboard) when the sheet opens, but skip
126+ // it for a short list that's faster to scan by eye than to filter.
127+ val searchFocus = remember { FocusRequester() }
128+ LaunchedEffect(Unit) {
129+ if (commands.size > NetworkConfig.pickerSearchAutofocusThreshold) {
130+ runCatching { searchFocus.requestFocus() }
131+ }
132+ }
133+ OutlinedTextField(
134+ value = query,
135+ onValueChange = { query = it },
136+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 8.dp).focusRequester(searchFocus),
137+ placeholder = { Text(stringResource(R.string.search_commands)) },
138+ leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
139+ trailingIcon = if (query.isNotEmpty()) {
140+ {
141+ IconButton(onClick = { query = "" }) {
142+ Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
143+ }
144+ }
145+ } else null,
146+ singleLine = true,
147+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
148+ keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
149+ )
150+ // Dedupe the source list once, then filter the distinct list.
151+ val distinct = remember(commands) { commands.distinctBy { it.name } }
152+ val filtered = remember(distinct, query) {
153+ val q = query.trim()
154+ if (q.isEmpty()) distinct
155+ else distinct.filter {
156+ it.name.contains(q, ignoreCase = true) ||
157+ it.displayDescription.contains(q, ignoreCase = true)
158+ }
159+ }
160+ if (filtered.isEmpty()) {
161+ Text(
162+ stringResource(R.string.no_commands_match, query.trim()),
163+ style = MaterialTheme.typography.bodyMedium,
164+ color = MaterialTheme.colorScheme.onSurfaceVariant,
165+ modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
166+ )
167+ } else {
168+ LazyColumn(
169+ modifier = Modifier
170+ .heightIn(max = NetworkConfig.pickerSheetMaxHeightDp.dp)
171+ .imePadding()
172+ .navigationBarsPadding(),
173+ ) {
174+ items(filtered, key = { it.name }) { cmd ->
175+ val destructive = isDestructiveCommand(cmd.name)
176+ Column(
177+ modifier = Modifier
178+ .fillMaxWidth()
179+ .clickable(role = Role.Button) {
180+ haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
181+ if (destructive) {
182+ pendingCommandName = cmd.name
183+ } else {
184+ scope.launch { sheetState.hide() }.invokeOnCompletion { onDismiss() }
185+ onSelect(cmd)
186+ }
187+ }
188+ .semantics {
189+ if (destructive) stateDescription = destructiveStateLabel
190+ }
191+ .padding(horizontal = 24.dp, vertical = 12.dp),
192+ ) {
193+ Row(
194+ verticalAlignment = Alignment.CenterVertically,
195+ ) {
196+ Icon(Icons.AutoMirrored.Filled.PlaylistPlay, contentDescription = null)
197+ Text(
198+ "/${cmd.name}",
199+ style = MaterialTheme.typography.bodyLarge,
200+ modifier = Modifier.padding(start = 8.dp),
201+ )
202+ }
203+ Text(
204+ cmd.displayDescription,
205+ style = MaterialTheme.typography.bodySmall,
206+ color = MaterialTheme.colorScheme.onSurfaceVariant,
207+ maxLines = 2,
208+ overflow = TextOverflow.Ellipsis,
209+ modifier = Modifier.padding(start = 28.dp, top = 2.dp),
210+ )
211+ }
212+ }
213+ }
214+ }
215+ }
216+ }
217+
218+ // Confirmation for commands whose name suggests they may discard work or reset state,
219+ // so a tap doesn't irrevocably run e.g. /clear or /compact without consent.
220+ pendingCommand?.let { cmd ->
221+ AlertDialog(
222+ onDismissRequest = { pendingCommandName = null },
223+ title = { Text(stringResource(R.string.run_command_title, cmd.name)) },
224+ text = { Text(stringResource(R.string.run_command_text, "/${cmd.name}")) },
225+ confirmButton = {
226+ TextButton(onClick = {
227+ pendingCommandName = null
228+ scope.launch { sheetState.hide() }.invokeOnCompletion { onDismiss() }
229+ onSelect(cmd)
230+ }) { Text(stringResource(R.string.run)) }
231+ },
232+ dismissButton = {
233+ TextButton(onClick = { pendingCommandName = null }) { Text(stringResource(R.string.cancel)) }
234+ },
235+ )
236+ }
237+}
238+
239+/**
240+ * Heuristic for commands that may be destructive (discard work / reset state) and so
241+ * warrant a confirmation step before invocation. Matches common opencode slash-command
242+ * names; non-matching commands invoke immediately.
243+ */
244+private fun isDestructiveCommand(name: String): Boolean {
245+ val n = name.lowercase()
246+ return n in setOf("clear", "compact", "delete", "reset", "remove", "wipe", "drop", "purge") ||
247+ n.startsWith("clear-") || n.startsWith("compact-") || n.startsWith("reset-")
248+}
app/src/main/java/soy/iko/opencode/ui/chat/ConversationExport.ktnew file+236
@@ -0,0 +1,236 @@
1+package soy.iko.opencode.ui.chat
2+
3+import soy.iko.opencode.data.model.AssistantMessage
4+import soy.iko.opencode.data.model.FilePart
5+import soy.iko.opencode.data.model.MessageWithParts
6+import soy.iko.opencode.data.model.ReasoningPart
7+import soy.iko.opencode.data.model.TextPart
8+import soy.iko.opencode.data.model.ToolCompleted
9+import soy.iko.opencode.data.model.ToolError
10+import soy.iko.opencode.data.model.ToolPart
11+import soy.iko.opencode.data.model.ToolRunning
12+import soy.iko.opencode.data.model.UserMessage
13+import soy.iko.opencode.data.model.sourcePath
14+import soy.iko.opencode.data.network.NetworkConfig
15+import soy.iko.opencode.data.network.OpencodeJson
16+import kotlinx.serialization.builtins.ListSerializer
17+import java.text.SimpleDateFormat
18+import java.util.Date
19+import java.util.Locale
20+
21+/**
22+ * Render a conversation as a lossless JSON document for sharing/exporting. Unlike
23+ * [buildConversationMarkdown], this preserves the full fidelity of the server's
24+ * message stream: tool outputs are not truncated, file parts keep their source
25+ * objects and data URLs, reasoning keeps its raw text, and the polymorphic
26+ * `type`/`role` discriminators are retained. That makes the JSON export suitable
27+ * for re-importing into another client, for auditing exactly what the agent did,
28+ * and for bug reports where the Markdown summary would have dropped detail.
29+ *
30+ * Uses the shared [OpencodeJson] so the on-the-wire shape round-trips — the same
31+ * serializer that decoded the messages re-encodes them, including the `Unknown*`
32+ * fallback variants for parts/roles the client doesn't otherwise model.
33+ */
34+fun buildConversationJson(messages: List<MessageWithParts>): String =
35+ try {
36+ // Explicit serializer (mirrors MessageCacheStore) rather than the reified
37+ // encodeToString overload, so the polymorphic Part/MessageInfo discriminators
38+ // registered in OpencodeJson are exercised on the way back out.
39+ OpencodeJson.encodeToString(ListSerializer(MessageWithParts.serializer()), messages)
40+ } catch (e: Throwable) {
41+ // Fall back to an explicit error document rather than crashing the share
42+ // flow; a serialization failure here is most likely a model invariant bug
43+ // and a silent empty string would be harder to diagnose than this stub.
44+ "{\"error\":\"conversation_json_export_failed\",\"message\":\"${e.message ?: e.javaClass.simpleName}\"}"
45+ }
46+
47+/**
48+ * Render a conversation as Markdown for sharing/exporting. Each message becomes a
49+ * role-prefixed section containing its text parts; reasoning is exported as a blockquote
50+ * and tool calls are summarized so the transcript shows what the agent *did*, not just
51+ * what it said. Pure (no Android deps) so it can be unit-tested directly.
52+ */
53+fun buildConversationMarkdown(messages: List<MessageWithParts>, title: String?): String {
54+ val sb = StringBuilder(messages.size * 128)
55+ if (!title.isNullOrBlank()) {
56+ sb.append("# ").append(escapeMarkdown(title.trim())).append("\n\n")
57+ }
58+ appendMetadataHeader(sb, messages)
59+ for (message in messages) {
60+ val heading = when (message.info) {
61+ is UserMessage -> "## You"
62+ is AssistantMessage -> "## opencode"
63+ else -> null
64+ } ?: continue
65+ // The assistant's reply text is already Markdown (code fences, bold, headings);
66+ // escaping it would render the export as backslash noise. Only escape user-authored
67+ // text, which isn't expected to be Markdown.
68+ val escapeText = message.info is UserMessage
69+ val body = message.parts
70+ .mapNotNull { part ->
71+ when (part) {
72+ is TextPart -> part.text.takeIf { it.isNotBlank() }
73+ ?.let { if (escapeText) escapeMarkdown(it) else it }
74+ is ReasoningPart -> part.text.takeIf { it.isNotBlank() }
75+ ?.let { text -> text.trim().lines().joinToString("\n") { "> _${escapeMarkdown(it)}_" } }
76+ is ToolPart -> formatToolCall(part)
77+ is FilePart -> {
78+ // Image/file attachments aren't portable in a text export; emit a
79+ // labeled placeholder so the transcript records that one was present
80+ // (useful for debugging) instead of silently dropping it.
81+ val name = part.sourcePath ?: part.url ?: part.filename ?: "attachment"
82+ if (part.mime?.startsWith("image/") == true) "_[image: ${escapeMarkdown(name)}]_"
83+ else "_[file: ${escapeMarkdown(name)}]_"
84+ }
85+ else -> null
86+ }
87+ }
88+ .joinToString("\n\n")
89+ .trim()
90+ if (body.isEmpty()) continue
91+ sb.append(heading).append("\n\n")
92+ // Per-message timestamp (ISO, local zone) so an exported transcript is useful for
93+ // support/debugging — without it only the metadata header's global range has timing.
94+ message.info.time?.created?.let { ts -> sb.append("_").append(formatExportTimestamp(ts)).append("_\n\n") }
95+ sb.append(body).append("\n\n")
96+ }
97+ return sb.toString().trimEnd()
98+}
99+
100+/**
101+ * Render a single message as Markdown for per-message sharing. A trimmed-down version of
102+ * [buildConversationMarkdown] that emits just this message's role heading + body (no metadata
103+ * header), so a shared single reply/exchange is self-contained without dragging the whole
104+ * transcript. Returns null when the message has no exportable body (e.g. an image-only prompt).
105+ */
106+fun buildMessageMarkdown(message: MessageWithParts): String? {
107+ val heading = when (message.info) {
108+ is UserMessage -> "## You"
109+ is AssistantMessage -> "## opencode"
110+ else -> return null
111+ }
112+ val escapeText = message.info is UserMessage
113+ val body = message.parts
114+ .mapNotNull { part ->
115+ when (part) {
116+ is TextPart -> part.text.takeIf { it.isNotBlank() }
117+ ?.let { if (escapeText) escapeMarkdown(it) else it }
118+ is ReasoningPart -> part.text.takeIf { it.isNotBlank() }
119+ ?.let { text -> text.trim().lines().joinToString("\n") { "> _${escapeMarkdown(it)}_" } }
120+ is ToolPart -> formatToolCall(part)
121+ is FilePart -> {
122+ val name = part.sourcePath ?: part.url ?: part.filename ?: "attachment"
123+ if (part.mime?.startsWith("image/") == true) "_[image: ${escapeMarkdown(name)}]_"
124+ else "_[file: ${escapeMarkdown(name)}]_"
125+ }
126+ else -> null
127+ }
128+ }
129+ .joinToString("\n\n")
130+ .trim()
131+ if (body.isEmpty()) return null
132+ return buildString {
133+ append(heading).append("\n\n")
134+ message.info.time?.created?.let { ts -> append("_").append(formatExportTimestamp(ts)).append("_\n\n") }
135+ append(body)
136+ }
137+}
138+
139+/** Format an epoch-millis as a locale-stable ISO-ish timestamp for the export. Thread-confined
140+ * to the exporting coroutine, so a shared SimpleDateFormat (not thread-safe) is fine. */
141+private fun formatExportTimestamp(epochMillis: Long): String =
142+ SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date(epochMillis))
143+
144+/** Append a metadata header (message counts + timestamp range) so a shared transcript has
145+ * context for support/debugging. Skipped when there are no messages. */
146+private fun appendMetadataHeader(sb: StringBuilder, messages: List<MessageWithParts>) {
147+ if (messages.isEmpty()) return
148+ val userCount = messages.count { it.info is UserMessage }
149+ val assistantCount = messages.count { it.info is AssistantMessage }
150+ val firstTime = messages.firstOrNull()?.info?.time?.created
151+ val lastTime = messages.lastOrNull()?.info?.time?.created
152+ sb.append("> ")
153+ // Pluralize each noun independently (a prior bug keyed both on userCount, so "user" was
154+ // never pluralized and "message" was pluralized based on the wrong count).
155+ sb.append("$userCount user")
156+ if (userCount != 1) sb.append("s")
157+ sb.append(" · $assistantCount assistant message")
158+ if (assistantCount != 1) sb.append("s")
159+ if (firstTime != null && lastTime != null) {
160+ sb.append(" · ${formatExportTimestamp(firstTime)} → ${formatExportTimestamp(lastTime)}")
161+ }
162+ sb.append("\n\n---\n\n")
163+}
164+
165+/** Render a tool call as a compact, readable blockquote summary: the tool name, its
166+ * human-readable title (if any), and a truncated output/error. Keeps the exported
167+ * transcript useful when the agent's work (edits, command runs) is the substance. */
168+private fun formatToolCall(part: ToolPart): String? {
169+ val title = when (val s = part.state) {
170+ is ToolRunning -> s.title
171+ is ToolCompleted -> s.title
172+ else -> null
173+ }
174+ val detail = when (val s = part.state) {
175+ is ToolCompleted -> s.output?.takeIf { it.isNotBlank() }?.let { truncateOutput(it) }
176+ is ToolError -> s.error?.takeIf { it.isNotBlank() }?.let { "Error: ${truncateOutput(it)}" }
177+ else -> null
178+ }
179+ val status = when (part.state) {
180+ is ToolCompleted -> null
181+ is ToolError -> " — error"
182+ is ToolRunning -> " — running"
183+ else -> null
184+ }
185+ val head = "**${part.tool}**${status ?: ""}"
186+ val titleLine = title?.takeIf { it.isNotBlank() }?.let { escapeMarkdown(it) }
187+ return buildList {
188+ add("> $head")
189+ if (titleLine != null) add("> $titleLine")
190+ // Prefix every detail line with "> " so the fenced code block stays inside the
191+ // blockquote — otherwise lines after the first fall out of the quote.
192+ if (detail != null) {
193+ val quoted = detail.lines().joinToString("\n") { "> $it" }
194+ // Use a fence longer than the longest backtick run in the content, so tool output
195+ // that itself contains a ``` sequence (shell output echoing markdown, etc.) can't
196+ // prematurely close the block and make the rest of the transcript render broken.
197+ val fence = "`".repeat(maxOf(3, longestBacktickRun(detail) + 1))
198+ add(">\n> $fence\n$quoted\n> $fence")
199+ }
200+ }.joinToString("\n").takeIf { it.isNotBlank() }
201+}
202+
203+/** Length of the longest consecutive run of backticks in [text] (0 if none). */
204+private fun longestBacktickRun(text: String): Int {
205+ var max = 0
206+ var current = 0
207+ for (c in text) {
208+ if (c == '`') {
209+ current++
210+ if (current > max) max = current
211+ } else {
212+ current = 0
213+ }
214+ }
215+ return max
216+}
217+
218+private fun truncateOutput(output: String): String {
219+ if (output.length <= NetworkConfig.exportToolOutputLimitChars) return output
220+ return output.take(NetworkConfig.exportToolOutputLimitChars) + "\n… (truncated)"
221+}
222+
223+/** Escape markdown special characters so user/model text doesn't produce malformed markdown.
224+ * Extends the basic set with [ ] ( ) - + ! so user text containing links, list markers, or
225+ * image syntax doesn't render as live Markdown in the exported transcript. */
226+private fun escapeMarkdown(text: String): String {
227+ if (text.isEmpty()) return text
228+ val sb = StringBuilder(text.length + 16)
229+ for (c in text) {
230+ when (c) {
231+ '\\', '*', '_', '#', '`', '>', '[', ']', '(', ')', '!', '+', '-' -> sb.append('\\')
232+ }
233+ sb.append(c)
234+ }
235+ return sb.toString()
236+}
app/src/main/java/soy/iko/opencode/ui/chat/CostFormat.ktnew file+60
@@ -0,0 +1,60 @@
1+package soy.iko.opencode.ui.chat
2+
3+import soy.iko.opencode.data.model.Tokens
4+
5+// NumberFormat.getNumberInstance performs an expensive ICU locale lookup + object
6+// construction on every call. Reuse a thread-local instance so repeated calls (e.g.
7+// when the message list re-seeds after a reconnect and every visible assistant bubble
8+// recomposes at once) don't each pay that cost. Thread-local because NumberFormat is
9+// not thread-safe.
10+internal val tokenNumberFormat: ThreadLocal<java.text.NumberFormat> = ThreadLocal.withInitial {
11+ java.text.NumberFormat.getNumberInstance(java.util.Locale.US)
12+}
13+
14+internal fun formatTokens(tokens: Tokens, format: String): String {
15+ val nf = tokenNumberFormat.get()!!
16+ return format.format(nf.format(tokens.input), nf.format(tokens.output))
17+}
18+
19+internal fun formatCost(cost: Double, shortFormat: String, longFormat: String): String =
20+ // Locale.US so the formatting is stable regardless of device locale (avoids
21+ // non-ASCII digits or comma decimal separators in a dollar amount). The format
22+ // string itself is localized via strings.xml so the currency symbol can be
23+ // adapted by translators.
24+ if (cost < 0.01) String.format(java.util.Locale.US, longFormat, cost)
25+ else String.format(java.util.Locale.US, shortFormat, cost)
26+
27+/** Assemble the single-line cost/tokens summary for an assistant bubble. Returns null when the
28+ * message isn't complete, when neither cost nor tokens were reported, or when every figure is
29+ * zero (so the bubble doesn't show a meaningless "0 in · 0 out • $0.0000"). Extracted from
30+ * [AssistantBlock] to keep that function's cyclomatic complexity under the detekt threshold.
31+ * Reasoning and cache segments are added only when non-zero. */
32+internal fun buildCostSummary(
33+ isComplete: Boolean,
34+ tokens: Tokens?,
35+ cost: Double?,
36+ tokenFormat: String,
37+ reasoningFormat: String,
38+ cacheReadFormat: String,
39+ cacheWriteFormat: String,
40+ costShort: String,
41+ costLong: String,
42+): String? {
43+ if (!isComplete || (cost == null && tokens == null)) return null
44+ val nf = tokenNumberFormat.get()!!
45+ return buildList {
46+ // Skip an all-zero token count (e.g. a completed message that reported no usage) so
47+ // the bubble doesn't show a meaningless "0 in · 0 out".
48+ tokens?.takeIf { it.input > 0 || it.output > 0 }?.let { add(formatTokens(it, tokenFormat)) }
49+ tokens?.takeIf { it.reasoning > 0 }?.let {
50+ add(reasoningFormat.format(nf.format(it.reasoning)))
51+ }
52+ tokens?.cache?.takeIf { it.read > 0 }?.let {
53+ add(cacheReadFormat.format(nf.format(it.read)))
54+ }
55+ tokens?.cache?.takeIf { it.write > 0 }?.let {
56+ add(cacheWriteFormat.format(nf.format(it.write)))
57+ }
58+ cost?.takeIf { it > 0 }?.let { add(formatCost(it, costShort, costLong)) }
59+ }.takeIf { it.isNotEmpty() }?.joinToString(" • ")
60+}
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.ktnew file+1418

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.ktnew file+330

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.ktnew file+811

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.ktnew file+331

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/chat/TodoPlan.ktnew file+205

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/chat/TtsController.ktnew file+278

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/chat/ViewSourceDialog.ktnew file+76

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/AppTopBar.ktnew file+82

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/Autofill.ktnew file+49

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/ChatTextPrefs.ktnew file+54

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/CodeHighlighter.ktnew file+632

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/CommandPalette.ktnew file+171

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/CommonStates.ktnew file+170

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/CompactNavBar.ktnew file+106

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.ktnew file+266

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/DiffView.ktnew file+662

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/EmptyState.ktnew file+77

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/GatedHapticFeedback.ktnew file+33

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/LargeScreenNavRail.ktnew file+109

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/LoadingSize.ktnew file+12

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/LoadingSpinner.ktnew file+40

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.ktnew file+902

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.ktdeleted−479

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/PaletteAction.ktnew file+11

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/ReducedMotion.ktnew file+131

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/RelativeTimeUnit.ktnew file+56

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.ktnew file+606

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/SectionHeader.ktnew file+25

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/SkeletonRow.ktnew file+97

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/TimeFormat.ktnew file+194

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.ktnew file+1043

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.ktnew file+281

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/file/FileViewModel.ktnew file+91

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.ktnew file+1323

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/file/TwoPaneFileBrowser.ktnew file+117

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/mcp/McpScreen.ktnew file+521

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/mcp/McpServerInfo.ktnew file+76

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/mcp/McpViewModel.ktnew file+173

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.ktdeleted−590

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/screens/ConnectScreen.ktdeleted−222

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.ktdeleted−402

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchScreen.ktnew file+430

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchViewModel.ktnew file+369

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.ktnew file+353

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.ktnew file+355

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.ktnew file+710

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.ktnew file+256

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/server/ServerSettingsScreen.ktnew file+428

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/server/ServerSettingsViewModel.ktnew file+226

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/session/NewSessionDialog.ktnew file+315

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.ktnew file+2064

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.ktnew file+1124

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.ktnew file+292

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.ktnew file+689

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.ktnew file+1660

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/theme/Color.ktdeleted−19

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/theme/Shapes.ktnew file+19

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt+179−38

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/theme/Type.ktdeleted−15

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/theme/Typography.ktnew file+136

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/usage/UsageAggregation.ktnew file+152

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/usage/UsageScreen.ktnew file+435

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/usage/UsageViewModel.ktnew file+161

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/util/Contexts.ktnew file+15

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/util/Coroutines.ktnew file+56

This diff is too big to show here.

app/src/main/res/drawable/ic_action_block.xmlnew file+10

This diff is too big to show here.

app/src/main/res/drawable/ic_action_check.xmlnew file+9

This diff is too big to show here.

app/src/main/res/drawable/ic_action_refresh.xmlnew file+9

This diff is too big to show here.

app/src/main/res/drawable/ic_action_reply.xmlnew file+9

This diff is too big to show here.

app/src/main/res/drawable/ic_action_stop.xmlnew file+9

This diff is too big to show here.

app/src/main/res/drawable/ic_launcher_foreground.xmlnew file+13

This diff is too big to show here.

app/src/main/res/drawable/ic_stat_notify.xmlnew file+9

This diff is too big to show here.

app/src/main/res/drawable/widget_background.xmlnew file+8

This diff is too big to show here.

app/src/main/res/layout/widget_session_item.xmlnew file+21

This diff is too big to show here.

app/src/main/res/layout/widget_sessions.xmlnew file+64

This diff is too big to show here.

app/src/main/res/mipmap-anydpi/ic_launcher.xmlnew file+6

This diff is too big to show here.

app/src/main/res/values-es/strings.xmlnew file+902

This diff is too big to show here.

app/src/main/res/values-es/strings_cross_ux.xmlnew file+7

This diff is too big to show here.

app/src/main/res/values-es/strings_diag_ux.xmlnew file+4

This diff is too big to show here.

app/src/main/res/values-es/strings_notif_ux.xmlnew file+5

This diff is too big to show here.

app/src/main/res/values-es/strings_settings_ux.xmlnew file+12

This diff is too big to show here.

app/src/main/res/values-night/colors.xml+15−1

This diff is too big to show here.

app/src/main/res/values-night/themes.xml+2−2

This diff is too big to show here.

app/src/main/res/values/colors.xml+19−1

This diff is too big to show here.

app/src/main/res/values/strings.xml+925

This diff is too big to show here.

app/src/main/res/values/strings_cross_ux.xmlnew file+7

This diff is too big to show here.

app/src/main/res/values/strings_diag_ux.xmlnew file+4

This diff is too big to show here.

app/src/main/res/values/strings_notif_ux.xmlnew file+8

This diff is too big to show here.

app/src/main/res/values/strings_settings_ux.xmlnew file+12

This diff is too big to show here.

app/src/main/res/values/themes.xml+16−7

This diff is too big to show here.

app/src/main/res/xml/data_extraction_rules.xmlnew file+15

This diff is too big to show here.

app/src/main/res/xml/file_paths.xmlnew file+12

This diff is too big to show here.

app/src/main/res/xml/network_security_config.xmlnew file+15

This diff is too big to show here.

app/src/main/res/xml/sessions_widget_info.xmlnew file+10

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/M5DecodingTest.ktnew file+144

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/M6Test.ktnew file+102

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/OpencodeJsonTest.ktnew file+122

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/UxTest.ktnew file+104

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/core/ConnectUrlTest.ktdeleted−69

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/model/TodoPlanTest.ktnew file+90

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/network/EventStreamClientTest.ktnew file+37

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/network/NormalizeBaseUrlTest.ktnew file+42

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/network/OpencodeApiClientTest.ktnew file+115

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/network/WithRetryTest.ktnew file+254

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/repo/CrashLoggerScrubTest.ktnew file+46

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/repo/ErrorKindTest.ktnew file+60

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/repo/MessageStoreTest.ktnew file+385

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/repo/OutboxStoreTest.ktnew file+44

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/repo/SessionRepositoryTest.ktnew file+73

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/di/UnreadTrackingTest.ktnew file+56

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/chat/ChatViewModelTest.ktnew file+650

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/chat/ConversationExportTest.ktnew file+120

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/chat/ToolDurationFormatTest.ktnew file+45

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/components/CodeHighlighterLanguageTest.ktnew file+66

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/components/DiffParseTest.ktnew file+55

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/components/ExtractCodeTextTest.ktnew file+56

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/components/ExtractFenceLanguageTest.ktnew file+28

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/file/FileBrowserViewModelTest.ktnew file+231

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/file/FileViewModelTest.ktnew file+70

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/mcp/McpParserTest.ktnew file+52

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/search/GlobalSearchViewModelTest.ktnew file+120

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/server/CertPinValidationTest.ktnew file+39

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/server/ServerEditViewModelTest.ktnew file+425

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/server/ServerSettingsViewModelTest.ktnew file+287

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/session/SessionListViewModelTest.ktnew file+649

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.ktnew file+486

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/usage/UsageAggregationTest.ktnew file+67

This diff is too big to show here.

build.gradle.kts+1−2

This diff is too big to show here.

config/detekt/detekt.ymlnew file+68

This diff is too big to show here.

flake.lock+3−3

This diff is too big to show here.

flake.nix+19−29

This diff is too big to show here.

generated/com/novi/bincode/BincodeDeserializer.javadeleted−38

This diff is too big to show here.

generated/com/novi/bincode/BincodeSerializer.javadeleted−33

This diff is too big to show here.

generated/com/novi/serde/ArrayLen.javadeleted−12

This diff is too big to show here.

generated/com/novi/serde/BinaryDeserializer.javadeleted−195

This diff is too big to show here.

generated/com/novi/serde/BinarySerializer.javadeleted−146

This diff is too big to show here.

generated/com/novi/serde/Bytes.javadeleted−54

This diff is too big to show here.

generated/com/novi/serde/DeserializationError.javadeleted−11

This diff is too big to show here.

generated/com/novi/serde/Deserializer.javadeleted−56

This diff is too big to show here.

generated/com/novi/serde/Int128.javadeleted−11

This diff is too big to show here.

generated/com/novi/serde/SerializationError.javadeleted−11

This diff is too big to show here.

generated/com/novi/serde/Serializer.javadeleted−58

This diff is too big to show here.

generated/com/novi/serde/Slice.javadeleted−40

This diff is too big to show here.

generated/com/novi/serde/Tuple2.javadeleted−40

This diff is too big to show here.

generated/com/novi/serde/Tuple3.javadeleted−46

This diff is too big to show here.

generated/com/novi/serde/Tuple4.javadeleted−54

This diff is too big to show here.

generated/com/novi/serde/Tuple5.javadeleted−61

This diff is too big to show here.

generated/com/novi/serde/Tuple6.javadeleted−67

This diff is too big to show here.

generated/com/novi/serde/Unit.javadeleted−20

This diff is too big to show here.

generated/com/novi/serde/Unsigned.javadeleted−11

This diff is too big to show here.

generated/soy/iko/opencode/shared/MessageStatus.javadeleted−150

This diff is too big to show here.

generated/soy/iko/opencode/shared/MessageView.javadeleted−131

This diff is too big to show here.

generated/soy/iko/opencode/shared/Requests.javadeleted−27

This diff is too big to show here.

generated/soy/iko/opencode/shared/Screen.javadeleted−150

This diff is too big to show here.

generated/soy/iko/opencode/shared/SessionView.javadeleted−77

This diff is too big to show here.

generated/soy/iko/opencode/shared/ToolView.javadeleted−86

This diff is too big to show here.

generated/soy/iko/opencode/shared/TraitHelpers.javadeleted−71

This diff is too big to show here.

generated/soy/iko/opencode/shared/ViewModel.javadeleted−185

This diff is too big to show here.

gradle.properties+4−5

This diff is too big to show here.

gradle/libs.versions.toml+58−29

This diff is too big to show here.

gradle/wrapper/gradle-wrapper.properties+1−1

This diff is too big to show here.

renovate.jsonnew file+32

This diff is too big to show here.

scripts/run-device.shnew file+106

This diff is too big to show here.

scripts/run.shnew file+95

This diff is too big to show here.

settings.gradle.kts−3

This diff is too big to show here.

shared/Cargo.lockdeleted−1358

This diff is too big to show here.

shared/Cargo.tomldeleted−31

This diff is too big to show here.

shared/src/app.rsdeleted−1051

This diff is too big to show here.

shared/src/bin/codegen.rsdeleted−35

This diff is too big to show here.

shared/src/lib.rsdeleted−17

This diff is too big to show here.

shared/src/protocol.rsdeleted−248

This diff is too big to show here.