Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
eba5f9b
fix(chat): restore thinking fallback and add live run progress
ayagmar Feb 16, 2026
f35195a
fix(streaming): harden event buffering and delta diagnostics
ayagmar Feb 16, 2026
19b45d4
fix(session): add sync-now and cross-device coherency warnings
ayagmar Feb 16, 2026
ce0a941
feat(extension): restore setStatus visibility with compact strip
ayagmar Feb 16, 2026
a7dc68e
refactor(ui): move secondary chat actions into overflow menu
ayagmar Feb 16, 2026
dabd378
test(core-rpc): cover thinking_end parser payload shapes
ayagmar Feb 16, 2026
0dc7dad
feat(sync): add bridge freshness checks and smart session refresh
ayagmar Feb 16, 2026
1a3a2b7
feat(ui): add side nav, inline run progress, and status toggle
ayagmar Feb 16, 2026
1847bae
feat(ui): implement approved UX quick wins
ayagmar Feb 16, 2026
e2537a3
fix(ui): replace blocking rail with collapsible left drawer
ayagmar Feb 16, 2026
d78db22
fix(chat): show jump-to-latest when scrolled away from bottom
ayagmar Feb 16, 2026
a7474cd
fix(ui): polish drawer layout and active nav styling
ayagmar Feb 16, 2026
f6b86da
feat(ui): animate drawer active indicator and selection
ayagmar Feb 16, 2026
501b7e7
fix(ui): unblock drawer and refine status/settings UX
ayagmar Feb 16, 2026
f9187f9
feat(chat): add context usage chip and compact-now action
ayagmar Feb 16, 2026
e90d36e
fix(chat): harden slash flows and bridge tree state sync
ayagmar Feb 17, 2026
3ba35b3
fix(chat): preserve draft and clear errors in slash flows
ayagmar Feb 17, 2026
d73b7b6
fix(docs): update extension status visibility and enhance final accep…
ayagmar Feb 17, 2026
9851be2
fix(chat): clear sent images during in-flight draft edits
ayagmar Feb 17, 2026
c720f3e
fix(chat): clear stale streaming controls on turn end
ayagmar Feb 17, 2026
98f2a39
feat(chat-ui): add tap-to-preview for attached images
ayagmar Feb 17, 2026
5e3ce08
fix(chat-ui): make timeline autoscroll smoother during streams
ayagmar Feb 17, 2026
d5ad447
feat(chat-ui): show compactions and cost in context chip
ayagmar Feb 17, 2026
44031d6
fix(network): improve reconnect recovery after socket drops
ayagmar Feb 17, 2026
e91f77a
test(sessions): assert compaction count parsing in stats
ayagmar Feb 17, 2026
d624f9e
fix(chat-ui): improve multiline input on mobile
ayagmar Feb 17, 2026
0dc9dcb
fix(chat): improve context accuracy and stuck-run recovery
ayagmar Feb 17, 2026
d19b18b
fix(chat): harden context label and streaming state handling
ayagmar Feb 17, 2026
eef5da3
fix(sessions): harden rpc stats/model parsing edge cases
ayagmar Feb 17, 2026
63694b7
refactor(chat): reduce timeline complexity and harden parsing
ayagmar Feb 17, 2026
a661dd9
refactor(chat): simplify prompt flow and autoscroll state
ayagmar Feb 17, 2026
efdbb79
fix(test): cancel viewModelScope after each test to prevent coroutine…
ayagmar Feb 17, 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
1,041 changes: 972 additions & 69 deletions app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import com.ayagmar.pimobile.corerpc.SetSteeringModeCommand
import com.ayagmar.pimobile.corerpc.SetThinkingLevelCommand
import com.ayagmar.pimobile.corerpc.SteerCommand
import com.ayagmar.pimobile.corerpc.SwitchSessionCommand
import com.ayagmar.pimobile.corerpc.TurnEndEvent
import com.ayagmar.pimobile.coresessions.SessionRecord
import com.ayagmar.pimobile.hosts.HostProfile
import kotlinx.coroutines.CoroutineScope
Expand All @@ -48,6 +49,7 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
Expand All @@ -69,6 +71,7 @@ import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import java.util.UUID
import kotlin.math.roundToInt

@Suppress("TooManyFunctions", "LargeClass")
class RpcSessionController(
Expand All @@ -91,6 +94,7 @@ class RpcSessionController(
private var connectionStateJob: Job? = null
private var streamingMonitorJob: Job? = null
private var resyncMonitorJob: Job? = null
private var reconnectRecoveryJob: Job? = null

override val rpcEvents: SharedFlow<RpcIncomingMessage> = _rpcEvents
override val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
Expand Down Expand Up @@ -287,6 +291,27 @@ class RpcSessionController(
}
}

override suspend fun getSessionFreshness(sessionPath: String): Result<SessionFreshnessSnapshot> {
return mutex.withLock {
runCatching {
val connection = ensureActiveConnection()
val bridgePayload =
buildJsonObject {
put("type", BRIDGE_GET_SESSION_FRESHNESS_TYPE)
put("sessionPath", sessionPath)
}

val bridgeResponse =
connection.requestBridge(
payload = bridgePayload,
expectedType = BRIDGE_SESSION_FRESHNESS_TYPE,
)

parseSessionFreshnessSnapshot(bridgeResponse.payload)
}
}
}

override suspend fun navigateTreeToEntry(entryId: String): Result<TreeNavigationResult> {
return mutex.withLock {
runCatching {
Expand Down Expand Up @@ -816,10 +841,12 @@ class RpcSessionController(
connectionStateJob?.cancel()
streamingMonitorJob?.cancel()
resyncMonitorJob?.cancel()
reconnectRecoveryJob?.cancel()
rpcEventsJob = null
connectionStateJob = null
streamingMonitorJob = null
resyncMonitorJob = null
reconnectRecoveryJob = null

activeConnection?.disconnect()
activeConnection = null
Expand All @@ -835,6 +862,8 @@ class RpcSessionController(
connectionStateJob?.cancel()
streamingMonitorJob?.cancel()
resyncMonitorJob?.cancel()
reconnectRecoveryJob?.cancel()
reconnectRecoveryJob = null

rpcEventsJob =
scope.launch {
Expand All @@ -846,7 +875,29 @@ class RpcSessionController(
connectionStateJob =
scope.launch {
connection.connectionState.collect { state ->
_connectionState.value = state
when (state) {
ConnectionState.DISCONNECTED -> {
if (activeConnection === connection && activeContext != null) {
_connectionState.value = ConnectionState.RECONNECTING
scheduleReconnectRecovery(connection)
} else {
cancelReconnectRecovery()
_connectionState.value = ConnectionState.DISCONNECTED
_isStreaming.value = false
}
}

ConnectionState.CONNECTED -> {
cancelReconnectRecovery()
_connectionState.value = ConnectionState.CONNECTED
}

ConnectionState.CONNECTING,
ConnectionState.RECONNECTING,
-> {
_connectionState.value = state
}
}
}
}

Expand All @@ -855,7 +906,10 @@ class RpcSessionController(
connection.rpcEvents.collect { event ->
when (event) {
is AgentStartEvent -> _isStreaming.value = true
is AgentEndEvent -> _isStreaming.value = false
is AgentEndEvent,
is TurnEndEvent,
-> _isStreaming.value = false

else -> Unit
}
}
Expand All @@ -870,6 +924,43 @@ class RpcSessionController(
}
}

private fun scheduleReconnectRecovery(connection: PiRpcConnection) {
if (reconnectRecoveryJob?.isActive == true) {
return
}

reconnectRecoveryJob =
scope.launch {
delay(DISCONNECT_RECOVERY_DELAY_MS)

if (activeConnection !== connection || activeContext == null) {
return@launch
}

// Clear job reference before reconnect to avoid cancelling this coroutine
// via the CONNECTED state observer while reconnect() is in-flight.
reconnectRecoveryJob = null

runCatching {
connection.reconnect()
}.onFailure { error ->
Log.w(
TRANSPORT_LOG_TAG,
"Automatic reconnect after disconnect failed: ${error.message ?: "unknown"}",
)
if (activeConnection === connection) {
_connectionState.value = ConnectionState.DISCONNECTED
_isStreaming.value = false
}
}
}
}

private fun cancelReconnectRecovery() {
reconnectRecoveryJob?.cancel()
reconnectRecoveryJob = null
}

private fun ensureActiveConnection(): PiRpcConnection {
return requireNotNull(activeConnection) {
"No active session. Resume a session first."
Expand Down Expand Up @@ -924,11 +1015,14 @@ class RpcSessionController(
private const val SET_FOLLOW_UP_MODE_COMMAND = "set_follow_up_mode"
private const val BRIDGE_GET_SESSION_TREE_TYPE = "bridge_get_session_tree"
private const val BRIDGE_SESSION_TREE_TYPE = "bridge_session_tree"
private const val BRIDGE_GET_SESSION_FRESHNESS_TYPE = "bridge_get_session_freshness"
private const val BRIDGE_SESSION_FRESHNESS_TYPE = "bridge_session_freshness"
private const val BRIDGE_NAVIGATE_TREE_TYPE = "bridge_navigate_tree"
private const val BRIDGE_TREE_NAVIGATION_RESULT_TYPE = "bridge_tree_navigation_result"
private const val EVENT_BUFFER_CAPACITY = 256
private const val DEFAULT_TIMEOUT_MS = 10_000L
private const val BASH_TIMEOUT_MS = 60_000L
private const val DISCONNECT_RECOVERY_DELAY_MS = 700L
private const val TRANSPORT_LOG_TAG = "RpcTransport"
}
}
Expand Down Expand Up @@ -1028,6 +1122,38 @@ private fun parseSessionTreeSnapshot(payload: JsonObject): SessionTreeSnapshot {
)
}

private fun parseSessionFreshnessSnapshot(payload: JsonObject): SessionFreshnessSnapshot {
val sessionPath = payload.stringField("sessionPath") ?: error("Session freshness response missing sessionPath")
val cwd = payload.stringField("cwd") ?: error("Session freshness response missing cwd")

val fingerprintPayload = runCatching { payload["fingerprint"]?.jsonObject }.getOrNull()
val lockPayload = runCatching { payload["lock"]?.jsonObject }.getOrNull()

val fingerprint =
SessionFreshnessFingerprint(
mtimeMs = fingerprintPayload.longField("mtimeMs") ?: 0L,
sizeBytes = fingerprintPayload.longField("sizeBytes") ?: 0L,
entryCount = fingerprintPayload.intField("entryCount") ?: 0,
lastEntryId = fingerprintPayload.stringField("lastEntryId"),
lastEntriesHash = fingerprintPayload.stringField("lastEntriesHash"),
)

val lock =
SessionLockMetadata(
cwdOwnerClientId = lockPayload.stringField("cwdOwnerClientId"),
sessionOwnerClientId = lockPayload.stringField("sessionOwnerClientId"),
isCurrentClientCwdOwner = lockPayload.booleanField("isCurrentClientCwdOwner") ?: false,
isCurrentClientSessionOwner = lockPayload.booleanField("isCurrentClientSessionOwner") ?: false,
)

return SessionFreshnessSnapshot(
sessionPath = sessionPath,
cwd = cwd,
fingerprint = fingerprint,
lock = lock,
)
}

private fun parseTreeNavigationResult(payload: JsonObject): TreeNavigationResult {
return TreeNavigationResult(
cancelled = payload.booleanField("cancelled") ?: false,
Expand Down Expand Up @@ -1056,6 +1182,7 @@ private fun parseModelInfo(data: JsonObject?): ModelInfo? {
name = model.stringField("name") ?: "Unknown Model",
provider = model.stringField("provider") ?: "unknown",
thinkingLevel = data.stringField("thinkingLevel") ?: "off",
contextWindow = model.intField("contextWindow"),
)
}

Expand Down Expand Up @@ -1087,7 +1214,7 @@ private fun parseBashResult(data: JsonObject?): BashResult {

@Suppress("MagicNumber", "LongMethod")
private fun parseSessionStats(data: JsonObject?): SessionStats {
val tokens = data?.get("tokens")?.jsonObject
val tokens = runCatching { data?.get("tokens")?.jsonObject }.getOrNull()

val inputTokens =
coalesceLong(
Expand Down Expand Up @@ -1134,12 +1261,45 @@ private fun parseSessionStats(data: JsonObject?): SessionStats {
coalesceInt(
data?.intField("toolResults"),
data?.intField("toolResultCount"),
data?.intField("toolCalls"),
)
val sessionPath =
coalesceString(
data?.stringField("sessionFile"),
data?.stringField("sessionPath"),
)
val compactionCount =
coalesceInt(
data?.intField("compactions"),
data?.intField("compactionCount"),
data?.intField("autoCompactions"),
)

val context = runCatching { data?.get("context")?.jsonObject }.getOrNull()
val contextUsedTokens =
coalesceLongOrNull(
context?.longField("used"),
context?.longField("tokens"),
context?.longField("current"),
data?.longField("contextUsedTokens"),
data?.longField("contextTokens"),
data?.longField("activeContextTokens"),
)
val contextWindowTokens =
coalesceLongOrNull(
context?.longField("window"),
context?.longField("max"),
data?.longField("contextWindow"),
)
val contextUsagePercent =
coalesceIntOrNull(
context?.intField("percent"),
context?.doubleField("percent")?.roundToInt(),
data?.intField("contextPercent"),
data?.doubleField("contextPercent")?.roundToInt(),
data?.intField("contextUsagePercent"),
data?.doubleField("contextUsagePercent")?.roundToInt(),
)

return SessionStats(
inputTokens = inputTokens,
Expand All @@ -1152,16 +1312,20 @@ private fun parseSessionStats(data: JsonObject?): SessionStats {
assistantMessageCount = assistantMessageCount,
toolResultCount = toolResultCount,
sessionPath = sessionPath,
compactionCount = compactionCount,
contextUsedTokens = contextUsedTokens,
contextWindowTokens = contextWindowTokens,
contextUsagePercent = contextUsagePercent,
)
}

private fun parseAvailableModels(data: JsonObject?): List<AvailableModel> {
val models = runCatching { data?.get("models")?.jsonArray }.getOrNull() ?: JsonArray(emptyList())

return models.mapNotNull { modelElement ->
val modelObject = modelElement.jsonObject
val modelObject = runCatching { modelElement.jsonObject }.getOrNull() ?: return@mapNotNull null
val id = modelObject.stringField("id") ?: return@mapNotNull null
val cost = modelObject["cost"]?.jsonObject
val cost = runCatching { modelObject["cost"]?.jsonObject }.getOrNull()

AvailableModel(
id = id,
Expand All @@ -1183,10 +1347,18 @@ private fun coalesceLong(vararg values: Long?): Long {
return values.firstOrNull { it != null } ?: 0L
}

private fun coalesceLongOrNull(vararg values: Long?): Long? {
return values.firstOrNull { it != null }
}

private fun coalesceInt(vararg values: Int?): Int {
return values.firstOrNull { it != null } ?: 0
}

private fun coalesceIntOrNull(vararg values: Int?): Int? {
return values.firstOrNull { it != null }
}

private fun coalesceDouble(vararg values: Double?): Double {
return values.firstOrNull { it != null } ?: 0.0
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ interface SessionController {
filter: String? = null,
): Result<SessionTreeSnapshot>

suspend fun getSessionFreshness(sessionPath: String): Result<SessionFreshnessSnapshot>

suspend fun navigateTreeToEntry(entryId: String): Result<TreeNavigationResult>

suspend fun cycleModel(): Result<ModelInfo?>
Expand Down Expand Up @@ -139,6 +141,28 @@ data class SessionTreeSnapshot(
val entries: List<SessionTreeEntry>,
)

data class SessionFreshnessSnapshot(
val sessionPath: String,
val cwd: String,
val fingerprint: SessionFreshnessFingerprint,
val lock: SessionLockMetadata,
)

data class SessionFreshnessFingerprint(
val mtimeMs: Long,
val sizeBytes: Long,
val entryCount: Int,
val lastEntryId: String?,
val lastEntriesHash: String?,
)

data class SessionLockMetadata(
val cwdOwnerClientId: String?,
val sessionOwnerClientId: String?,
val isCurrentClientCwdOwner: Boolean,
val isCurrentClientSessionOwner: Boolean,
)

data class SessionTreeEntry(
val entryId: String,
val parentId: String?,
Expand All @@ -165,6 +189,7 @@ data class ModelInfo(
val name: String,
val provider: String,
val thinkingLevel: String,
val contextWindow: Int? = null,
)

/**
Expand Down
Loading