Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
ca5cee1
fix(sync): reduce freshness warning noise
ayagmar Jul 13, 2026
7507e34
refactor(chat): project timeline into turns
ayagmar Jul 13, 2026
c3d4ab3
feat(chat): compact assistant and tool activity
ayagmar Jul 13, 2026
0db2f18
feat(chat): add session handoff details
ayagmar Jul 13, 2026
bee5f8e
feat(chat): streamline active-run composer
ayagmar Jul 13, 2026
ae56dc2
fix(chat): preserve reading position and unread state
ayagmar Jul 13, 2026
fa0532a
docs(chat): document chat experience v2
ayagmar Jul 13, 2026
a3ae36b
fix(chat): resolve experience review findings
ayagmar Jul 13, 2026
8d0ee4e
docs(chat): record review corrections
ayagmar Jul 13, 2026
d575821
fix(chat): pause reading scroll and preview session images
ayagmar Jul 15, 2026
a030b4e
docs(chat): note image preview and reading behavior
ayagmar Jul 15, 2026
0960b87
docs(plan): keep chat milestone in progress
ayagmar Jul 15, 2026
26cf787
feat(chat): polish images reading and accessibility
ayagmar Jul 16, 2026
deaa423
docs(perf): plan resume and session navigation overhaul
ayagmar Jul 17, 2026
62116b0
fix(chat): suppress stale session content during switches
ayagmar Jul 17, 2026
a7270df
perf(bridge): share parsed session revisions
ayagmar Jul 17, 2026
69d5feb
perf(sync): stage bootstrap and coalesce refreshes
ayagmar Jul 17, 2026
c042c73
test(chat): cover immediate session switch clearing
ayagmar Jul 17, 2026
20a0209
chore(perf): satisfy static performance gate
ayagmar Jul 17, 2026
3e2dd15
perf(chat): record staged bootstrap timings
ayagmar Jul 17, 2026
c23a504
perf(tree): cache filtered tree snapshots
ayagmar Jul 17, 2026
e28abc5
perf(bridge): coalesce revision parses
ayagmar Jul 17, 2026
2602d65
fix(tree): show cached navigation while updating
ayagmar Jul 17, 2026
b482db5
perf(bridge): log sanitized index timings
ayagmar Jul 17, 2026
861ae51
perf(sessions): throttle duplicate index refreshes
ayagmar Jul 17, 2026
3a3403d
test(sessions): cover refresh throttling
ayagmar Jul 17, 2026
03f5639
perf(sync): own staged bootstrap in controller
ayagmar Jul 17, 2026
11ff8dc
perf(bridge): stream and measure session parsing
ayagmar Jul 17, 2026
48113a1
perf(tree): refresh cached authoritative trees
ayagmar Jul 17, 2026
20da4fc
perf(sessions): back off failed index refreshes
ayagmar Jul 17, 2026
3083c5f
refactor(bridge): serialize control-plane messages
ayagmar Jul 17, 2026
e173b0d
perf(bridge): time cwd and lock setup
ayagmar Jul 17, 2026
cfadb17
feat(chat): move large tool details to bottom sheet
ayagmar Jul 17, 2026
3eeeeee
perf(bridge): increment freshness from bounded tails
ayagmar Jul 17, 2026
b588085
test(tree): cover cached authoritative replacement
ayagmar Jul 17, 2026
f627a51
perf(sessions): reuse authenticated host transports
ayagmar Jul 17, 2026
54236e4
feat(chat): persist text drafts in saved state
ayagmar Jul 17, 2026
cabe9d7
fix(ui): let app bars own safe insets
ayagmar Jul 17, 2026
ef09c48
feat(ui): add expanded navigation rail
ayagmar Jul 17, 2026
e6f5198
fix(chat): bound and label tool detail actions
ayagmar Jul 17, 2026
a8d28f5
perf(sync): time handshake and session switches
ayagmar Jul 17, 2026
8a16f2c
fix(security): avoid rendering session paths
ayagmar Jul 17, 2026
2149432
fix(chat): satisfy saved state lint contract
ayagmar Jul 17, 2026
08b94f7
docs(plan): add agp9 api37 java25 modernization handoff
ayagmar Jul 17, 2026
f5725d8
docs(build): record agp9 compatibility blocker
ayagmar Jul 21, 2026
c0f5a2a
docs(build): select supported agp9 matrix
ayagmar Jul 21, 2026
d3ddd2a
chore(build): migrate to agp 9 and jdk 25
ayagmar Jul 21, 2026
2ac45a2
fix(android): adopt api 37 platform behavior
ayagmar Jul 21, 2026
00051db
chore(bridge): update node runtime dependencies
ayagmar Jul 21, 2026
019bd95
test(build): align assertions with kotlin 2.4
ayagmar Jul 21, 2026
285c5c1
chore(ci): adopt jdk 25 and api 37
ayagmar Jul 21, 2026
d809ce7
test(bridge): cover sanitized tree failures
ayagmar Jul 21, 2026
95ef4b9
docs(build): record migration acceptance
ayagmar Jul 21, 2026
6386e58
test(bridge): make eviction check deterministic
ayagmar Jul 23, 2026
a2c27da
fix(benchmark): compile macrobenchmark sources
ayagmar Jul 23, 2026
328cbfc
docs(plans): reconcile chat and performance work
ayagmar Jul 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,30 @@ jobs:
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
- uses: actions/setup-java@v5
- name: Set up JDK 21 for stable detekt
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 21
- name: Set up JDK 25 build runtime
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 25
cache: gradle
- uses: android-actions/setup-android@v4
- name: Install Android SDK 36
run: sdkmanager "platforms;android-36" "build-tools;36.0.0"
- name: Install Android SDK 37
run: sdkmanager "platforms;android-37.0" "build-tools;37.0.0"
- name: Verify toolchain
run: |
java -version
./gradlew --version
test -f "$ANDROID_HOME/platforms/android-37.0/android.jar"
test -x "$ANDROID_HOME/build-tools/37.0.0/aapt2"
- name: Verify Android
run: ./gradlew clean ktlintCheck detekt test :app:lintDebug :app:assembleDebug :app:assembleRelease
run: ./gradlew clean ktlintCheck detekt test :benchmark:compileBenchmarkKotlin :app:lintDebug :app:assembleDebug :app:assembleRelease
- name: Compile Android test sources
run: ./gradlew :app:compileDebugAndroidTestKotlin
- name: Upload debug APK
uses: actions/upload-artifact@v7
with:
Expand All @@ -46,7 +60,7 @@ jobs:
version: 10.33.0
- uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
cache: pnpm
cache-dependency-path: bridge/pnpm-lock.yaml
- run: pnpm install --frozen-lockfile
Expand Down
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ Keep regression tests beside the owning module. Protocol fixtures belong in `cor

## Required commands

Use JDK 21, Node 22+, pnpm 10, Android SDK 36, and Pi 0.80.6+.
Use JDK 25 for Gradle and compilation, retain a JDK 21 toolchain for the stable detekt CLI, and install Android SDK platform 37.0/build-tools 37.0.0. Use Node 24 LTS+, pnpm 10, and Pi 0.80.6+.

```bash
./gradlew clean ktlintCheck detekt test :app:lintDebug :app:assembleDebug :app:assembleRelease
./gradlew clean ktlintCheck detekt test :benchmark:compileBenchmarkKotlin :app:lintDebug :app:assembleDebug :app:assembleRelease
(cd bridge && pnpm install --frozen-lockfile && pnpm run check && pnpm audit --prod)
```

Expand Down
35 changes: 19 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,18 +147,22 @@ App renders streaming text/tools

## Chat UX Highlights

- **Thinking blocks**: streaming reasoning appears separately and can be collapsed/expanded.
- **Tool cards**: tool args/output are grouped with icons and expandable output.
- **Turn-centered conversation**: each prompt groups its assistant activity and final answer into one coherent turn.
- **Quiet thinking disclosures**: reasoning stays collapsed and low-emphasis unless explicitly opened.
- **Compact tool activity**: completed tools collapse into tool-specific summaries; arguments, output, and diffs remain available on demand.
- **Edit diff viewer**: `edit` tool calls show before/after content.
- **Command palette**: insert slash commands quickly from the prompt field menu, including bridge-backed mobile commands.
- **Quick copy action**: copy the last assistant response from the chat header menu without typing `/copy`.
- **Stable active-run composer**: type inline during a run and deliver the draft as a follow-up or steer without a separate dialog.
- **Reading-position controls**: expanding details pauses live auto-scroll, loading older turns preserves position, and `N new` returns to live activity only when you choose.
- **Image previews**: attached images render from local Android URIs or documented image data embedded in the remote Pi session, with a full-screen preview.
- **Quick copy action**: copy an assistant answer inline or copy the latest response from session details.
- **Bash dialog**: execute shell commands with timeout/truncation handling and history.
- **Session status in chat**: shows the active session name and queued message count from pi state.
- **Session names in session browser**: active named sessions are surfaced more clearly in the Sessions header, rename dialog, and cards.
- **Session stats sheet**: token/cost/message/context counters, queued-message summary, and session path.
- **Session details and handoff sheet**: stats, paths, secondary actions, safe handoff summary, latest-response copy, and export.
- **Model picker**: provider-aware searchable model selection.
- **Tree navigator**: inspect branch points, filter views, jump in-place, or fork from chosen entries.
- **Session coherency guard**: warns on cross-device edits and offers **Sync now**.
- **Session coherency guard**: routine freshness refreshes stay quiet; explicit other-client conflicts and reload failures remain actionable.
- **Settings controls**: auto-compaction, auto-retry, steer/follow-up delivery modes, theme, and status-strip visibility.

## Troubleshooting
Expand Down Expand Up @@ -204,7 +208,7 @@ benchmark/ - Macrobenchmark / baseline profile scaffolding

### Running Tests

Use JDK 21, Android SDK 36, Node 22+, and pnpm 10 for this repo. See [the dependency matrix](docs/dependency-matrix.md).
Use JDK 25 for builds, a JDK 21 toolchain for stable detekt, Android SDK platform 37.0/build-tools 37.0.0, Node 24 LTS+, and pnpm 10. See [the dependency matrix](docs/dependency-matrix.md).

```bash
# Android tests
Expand All @@ -217,7 +221,8 @@ cd bridge && pnpm test
cd bridge && pnpm run check

# Complete non-device Android gate
./gradlew clean ktlintCheck detekt test :app:lintDebug :app:assembleDebug :app:assembleRelease
./gradlew clean ktlintCheck detekt test :benchmark:compileBenchmarkKotlin :app:lintDebug :app:assembleDebug :app:assembleRelease
./gradlew :app:compileDebugAndroidTestKotlin
```

### Logs to Watch
Expand Down Expand Up @@ -258,7 +263,7 @@ BRIDGE_PI_COMMAND=pi # Pi executable path/name; probed with --ve

### App Build Variants

Debug builds include logging and assertions. Release builds (if you make them) strip these for smaller size.
Debug builds include development logging and assertions. The repository-safe release build is unsigned/default and currently keeps minification disabled; see [`docs/release.md`](docs/release.md) before distribution.

## Security Notes

Expand All @@ -274,23 +279,21 @@ Debug builds include logging and assertions. Release builds (if you make them) s
## Limitations

- No offline mode - requires live connection to laptop
- Session history is fetched via `get_messages` and rendered in a capped window (no true server-side pagination yet)
- Tree navigation is MVP-level (functional, minimal rendering)
- Active history prefers the cursor-based `get_entries` projection and falls back to documented `get_messages` when needed; the UI renders capped windows, not true server-side pages
- Tree display is cached and generation-gated, but navigation still depends on the internal `pi-mobile-tree` extension because Pi 0.80.6 has no navigation RPC command
- Mobile keyboard shortcuts vary by device/IME

## Testing

See [docs/testing.md](docs/testing.md) for emulator setup and testing procedures.

Quick start:
Non-device quick check:
```bash
# Start emulator, build, install
./gradlew :app:installDebug

# Watch logs
adb logcat | grep -E "PiMobile|PerfMetrics"
./gradlew test :app:lintDebug :app:assembleDebug
```

Emulator/device, installation, ADB, benchmark, and manual acceptance commands are operator-owned and require the explicit phrase `debug mode`.

## License

MIT
41 changes: 21 additions & 20 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,20 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget

plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.serialization")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlinx.kover")
}

android {
namespace = "com.ayagmar.pimobile"
compileSdk = 36
compileSdk = 37
buildToolsVersion = "37.0.0"

defaultConfig {
applicationId = "com.ayagmar.pimobile"
minSdk = 26
targetSdk = 36
targetSdk = 37
versionCode = 1
versionName = "1.0"

Expand All @@ -33,12 +33,12 @@ android {
}

compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
sourceCompatibility = JavaVersion.VERSION_25
targetCompatibility = JavaVersion.VERSION_25
}

kotlin {
compilerOptions.jvmTarget.set(JvmTarget.JVM_21)
compilerOptions.jvmTarget.set(JvmTarget.JVM_25)
}

buildFeatures {
Expand All @@ -56,7 +56,8 @@ android {
checkReleaseBuilds = true
warningsAsErrors = true
disable += "GradleDependency"
disable += "OldTargetApi"
// Keep the fully supported Gradle 9.3.1 matrix instead of lint's newer 9.6.1 suggestion.
informational += "AndroidGradlePluginVersion"
baseline = file("lint-baseline.xml")
}
}
Expand All @@ -71,22 +72,22 @@ dependencies {
implementation(project(":core-sessions"))

implementation(platform("androidx.compose:compose-bom:2026.06.00"))
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.activity:activity-compose:1.9.1")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.4")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.4")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.4")
implementation("androidx.core:core-ktx:1.19.0")
implementation("androidx.activity:activity-compose:1.13.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.11.0")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.11.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.11.0")
implementation("androidx.navigation:navigation-compose:2.9.8")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
implementation("io.coil-kt:coil-compose:2.6.0")
implementation("io.coil-kt.coil3:coil-compose:3.5.0")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0")
implementation("com.squareup.okhttp3:okhttp:5.4.0")
implementation("com.google.android.gms:play-services-code-scanner:16.1.0")
implementation("com.github.jeziellago:compose-markdown:0.7.0")
implementation("io.github.java-diff-utils:java-diff-utils:4.15")
implementation("com.github.jeziellago:compose-markdown:0.7.2")
implementation("io.github.java-diff-utils:java-diff-utils:4.17")
implementation("io.noties:prism4j:2.0.0") {
exclude(group = "org.jetbrains", module = "annotations-java5")
}
Expand All @@ -95,10 +96,10 @@ dependencies {
debugImplementation("androidx.compose.ui:ui-test-manifest")

testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0")

androidTestImplementation(platform("androidx.compose:compose-bom:2026.06.00"))
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test:core-ktx:1.5.0")
androidTestImplementation("androidx.test.ext:junit:1.3.0")
androidTestImplementation("androidx.test:core-ktx:1.7.0")
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.ayagmar.pimobile.ui.sessions

import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.ayagmar.pimobile.ui.sessions

import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
Expand Down
10 changes: 10 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@
android:name="com.google.mlkit.vision.DEPENDENCIES"
android:value="barcode_ui" />

<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.files"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>

<activity
android:name=".MainActivity"
android:exported="true">
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/com/ayagmar/pimobile/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.ayagmar.pimobile
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.lifecycle.lifecycleScope
import com.ayagmar.pimobile.di.AppGraph
import com.ayagmar.pimobile.perf.PerformanceMetrics
Expand All @@ -20,6 +21,7 @@ class MainActivity : ComponentActivity() {
recordAppStart()

super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
PiMobileApp(appGraph = appGraph)
}
Expand Down
40 changes: 24 additions & 16 deletions app/src/main/java/com/ayagmar/pimobile/chat/ChatHistoryParsing.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,6 @@ import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject

internal fun historyWindowSignature(messages: List<JsonObject>): String {
if (messages.isEmpty()) return "empty"

val marker =
messages
.joinToString(separator = "|") { message ->
val role = message.stringField("role").orEmpty()
val entryId = message.stringField("entryId").orEmpty()
"$role:$entryId:${message.toString().hashCode()}"
}

return "${messages.size}:$marker"
}

internal fun extractHistoryMessageWindow(data: JsonObject?): HistoryMessageWindow {
val rawMessages = runCatching { data?.get("messages")?.jsonArray }.getOrNull() ?: JsonArray(emptyList())
val startIndex = (rawMessages.size - HISTORY_WINDOW_MAX_ITEMS).coerceAtLeast(0)
Expand Down Expand Up @@ -59,11 +45,12 @@ internal fun parseHistoryItems(
"user" -> {
val content = message["content"]
val text = extractUserText(content)
val imageCount = extractUserImageCount(content)
val images = extractUserImages(content)
ChatTimelineItem.User(
id = "history-user-$absoluteIndex",
text = text,
imageCount = imageCount,
imageCount = extractUserImageCount(content),
images = images,
)
}

Expand Down Expand Up @@ -118,6 +105,27 @@ internal fun extractUserText(content: JsonElement?): String {
}
}

internal fun extractUserImages(content: JsonElement?): List<ChatImageSource.Embedded> {
val blocks =
when (content) {
is JsonObject -> listOf(content)
else ->
runCatching {
content?.jsonArray?.mapNotNull { block ->
runCatching { block.jsonObject }.getOrNull()
}
}.getOrNull().orEmpty()
}

return blocks.mapNotNull { block ->
val type = block.stringField("type")?.lowercase().orEmpty()
if (!type.contains("image")) return@mapNotNull null
val data = block.stringField("data")?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
val mimeType = block.stringField("mimeType")?.takeIf { it.startsWith("image/") } ?: return@mapNotNull null
ChatImageSource.Embedded(base64Data = data, mimeType = mimeType)
}
}

internal fun extractUserImageCount(content: JsonElement?): Int {
return runCatching {
when (content) {
Expand Down
Loading