Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ data class UpdateSessionTimeRequest(

@kotlinx.serialization.Serializable
data class PromptRequest(
@kotlinx.serialization.SerialName("messageID") val messageId: String? = null,
val parts: List<PartInput>,
val agent: String = "build",
val model: ModelInput? = null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,13 @@ class OpenCodeRepository @Inject constructor() {
encodeDefaults = true // Include type in parts - server needs discriminator
}

private var okHttpClient: OkHttpClient = buildOkHttpClient()
private var retrofit: Retrofit = buildRetrofit()
private var restHttpClient: OkHttpClient = buildHttpClient(REST_READ_TIMEOUT_SECONDS)
private var sseHttpClient: OkHttpClient = buildHttpClient(SSE_READ_TIMEOUT_SECONDS)
private var retrofit: Retrofit = buildRetrofit(restHttpClient)
private var api: OpenCodeApi = retrofit.create(OpenCodeApi::class.java)
private var sseClient: SSEClient = SSEClient(okHttpClient)
private var sseClient: SSEClient = SSEClient(sseHttpClient)

private fun buildOkHttpClient(): OkHttpClient {
private fun buildHttpClient(readTimeoutSeconds: Long): OkHttpClient {
return OkHttpClient.Builder()
.addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BASIC
Expand All @@ -52,26 +53,28 @@ class OpenCodeRepository @Inject constructor() {
.build()
chain.proceed(request)
}
.connectTimeout(15, java.util.concurrent.TimeUnit.SECONDS)
.readTimeout(0, java.util.concurrent.TimeUnit.SECONDS)
.connectTimeout(CONNECT_TIMEOUT_SECONDS, java.util.concurrent.TimeUnit.SECONDS)
.writeTimeout(WRITE_TIMEOUT_SECONDS, java.util.concurrent.TimeUnit.SECONDS)
.readTimeout(readTimeoutSeconds, java.util.concurrent.TimeUnit.SECONDS)
.build()
}

private fun buildRetrofit(): Retrofit {
private fun buildRetrofit(client: OkHttpClient): Retrofit {
val url = if (baseUrl.startsWith("http")) baseUrl else "http://$baseUrl"
return Retrofit.Builder()
.baseUrl(url.trimEnd('/') + "/")
.client(okHttpClient)
.client(client)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
}

@Synchronized
private fun rebuildClients() {
okHttpClient = buildOkHttpClient()
retrofit = buildRetrofit()
restHttpClient = buildHttpClient(REST_READ_TIMEOUT_SECONDS)
sseHttpClient = buildHttpClient(SSE_READ_TIMEOUT_SECONDS)
retrofit = buildRetrofit(restHttpClient)
api = retrofit.create(OpenCodeApi::class.java)
sseClient = SSEClient(okHttpClient)
sseClient = SSEClient(sseHttpClient)
}

@Synchronized
Expand Down Expand Up @@ -126,7 +129,8 @@ class OpenCodeRepository @Inject constructor() {
text: String,
agent: String = "build",
model: Message.ModelInfo? = null,
attachments: List<ComposerImageAttachment> = emptyList()
attachments: List<ComposerImageAttachment> = emptyList(),
messageId: String? = null
): Result<Unit> = apiCall {
val parts = buildList {
if (text.isNotBlank()) add(PromptRequest.PartInput(type = "text", text = text))
Expand All @@ -142,6 +146,7 @@ class OpenCodeRepository @Inject constructor() {
}
}
val request = PromptRequest(
messageId = messageId,
parts = parts,
agent = agent,
model = model?.let { PromptRequest.ModelInput(it.providerId, it.modelId) }
Expand Down Expand Up @@ -229,5 +234,14 @@ class OpenCodeRepository @Inject constructor() {

companion object {
const val DEFAULT_SERVER = "http://localhost:4096"

// REST calls (prompt_async, getMessages, ...) get a bounded read timeout so a
// half-open connection fails fast instead of hanging forever. SSE is a
// long-lived stream that can be silent for minutes while the agent works, so
// it keeps an infinite read timeout on its own client.
private const val CONNECT_TIMEOUT_SECONDS = 15L
private const val WRITE_TIMEOUT_SECONDS = 60L
private const val REST_READ_TIMEOUT_SECONDS = 60L
private const val SSE_READ_TIMEOUT_SECONDS = 0L
}
}
33 changes: 31 additions & 2 deletions app/src/main/java/com/yage/opencode_client/ui/MainViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ data class AppState(
val sessionTodos: Map<String, List<TodoItem>> = emptyMap(),
val sendingSessionIds: Set<String> = emptySet(),
val sessionSendTimestamps: Map<String, Long> = emptyMap(),
val pendingOptimisticMessageIds: Set<String> = emptySet(),
val imageAttachments: List<ComposerImageAttachment> = emptyList(),
val hostProfiles: List<HostProfile> = emptyList(),
val currentHostProfileId: String? = null,
Expand Down Expand Up @@ -1502,6 +1503,7 @@ class MainViewModel @Inject constructor(
sessionTodos = emptyMap(),
sendingSessionIds = emptySet(),
sessionSendTimestamps = emptyMap(),
pendingOptimisticMessageIds = emptySet(),
agents = emptyList(),
providers = null,
filePathToShowInFiles = null,
Expand Down Expand Up @@ -1584,8 +1586,24 @@ class MainViewModel @Inject constructor(
val attachments = _state.value.imageAttachments
if (text.isEmpty() && attachments.isEmpty()) return

// Insert the optimistic user row immediately so the send feels instant, then
// clear the composer. The row carries the same deterministic msg_ id we send
// to the server, so reconciliation in loadMessages is pure id membership.
val messageId = makeServerId("msg")
val optimistic = buildOptimisticMessage(
sessionId = sessionId,
text = text,
attachments = attachments,
messageId = messageId,
parentMessageId = _state.value.messages.lastOrNull()?.info?.id
)

_state.update { state ->
state.copy(
messages = state.messages + optimistic,
pendingOptimisticMessageIds = state.pendingOptimisticMessageIds + messageId,
inputText = "",
imageAttachments = emptyList(),
sendingSessionIds = state.sendingSessionIds + sessionId,
sessionSendTimestamps = state.sessionSendTimestamps + (sessionId to System.currentTimeMillis())
)
Expand All @@ -1605,11 +1623,11 @@ class MainViewModel @Inject constructor(
attachments = attachments,
agent = agent,
model = model,
messageId = messageId,
onRefreshMessages = ::loadMessagesWithRetry,
onRefreshSessions = ::loadSessions,
onSuccess = {
settingsManager.setDraftText(sessionId, "")
_state.update { it.copy(imageAttachments = emptyList()) }
},
onComplete = {
_state.update { state ->
Expand All @@ -1632,7 +1650,18 @@ class MainViewModel @Inject constructor(
dispatchSend()
}
.onFailure { error ->
_state.update { it.copy(error = "Failed to restore session: ${errorMessageOrFallback(error, "unknown error")}") }
// Restoring the archived session failed; roll back the optimistic row.
_state.update { state ->
state.copy(
messages = state.messages.filter { m -> m.info.id != messageId },
pendingOptimisticMessageIds = state.pendingOptimisticMessageIds - messageId,
inputText = text,
imageAttachments = attachments,
sendingSessionIds = state.sendingSessionIds - sessionId,
sessionSendTimestamps = state.sessionSendTimestamps - sessionId,
error = "Failed to restore session: ${errorMessageOrFallback(error, "unknown error")}"
)
}
}
}
return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@ package com.yage.opencode_client.ui

import com.yage.opencode_client.data.model.ComposerImageAttachment
import com.yage.opencode_client.data.model.Message
import com.yage.opencode_client.data.model.MessageWithParts
import com.yage.opencode_client.data.model.Part
import com.yage.opencode_client.data.repository.OpenCodeRepository
import com.yage.opencode_client.util.SettingsManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.util.UUID

internal fun launchLoadSessions(
scope: CoroutineScope,
Expand Down Expand Up @@ -164,6 +167,7 @@ internal fun selectSessionState(
it.copy(
currentSessionId = sessionId,
messages = emptyList(),
pendingOptimisticMessageIds = emptySet(),
streamingPartTexts = emptyMap(),
streamingReasoningPart = null,
messageLimit = 30,
Expand All @@ -172,6 +176,19 @@ internal fun selectSessionState(
}
}

internal fun mergePendingOptimisticMessages(
serverMessages: List<MessageWithParts>,
currentState: AppState
): Pair<List<MessageWithParts>, Set<String>> {
val loadedIds = serverMessages.map { it.info.id }.toSet()
val pendingRows = currentState.messages.filter { m ->
currentState.pendingOptimisticMessageIds.contains(m.info.id) && m.info.id !in loadedIds
}
val merged = serverMessages + pendingRows
val prunedPending = currentState.pendingOptimisticMessageIds - loadedIds
return merged to prunedPending
}

internal fun launchLoadMessages(
scope: CoroutineScope,
repository: OpenCodeRepository,
Expand All @@ -197,8 +214,10 @@ internal fun launchLoadMessages(
val modelIndex = settingsManager?.getModelForSession(sessionId) ?: inferredModelIndex
val agentName = settingsManager?.getAgentForSession(sessionId) ?: inferredAgentName
state.update {
val (mergedMessages, prunedPending) = mergePendingOptimisticMessages(messages, it)
it.copy(
messages = messages,
messages = mergedMessages,
pendingOptimisticMessageIds = prunedPending,
messageLimit = limit,
isLoadingMessages = false,
selectedModelIndex = modelIndex ?: it.selectedModelIndex,
Expand Down Expand Up @@ -263,8 +282,10 @@ internal fun launchLoadMoreMessages(
.onSuccess { messages ->
if (sessionId == state.value.currentSessionId) {
state.update {
val (mergedMessages, prunedPending) = mergePendingOptimisticMessages(messages, it)
it.copy(
messages = messages,
messages = mergedMessages,
pendingOptimisticMessageIds = prunedPending,
messageLimit = newLimit,
isLoadingMessages = false
)
Expand Down Expand Up @@ -439,17 +460,17 @@ internal fun launchSendMessage(
attachments: List<ComposerImageAttachment> = emptyList(),
agent: String,
model: Message.ModelInfo?,
messageId: String,
onRefreshMessages: (String, Boolean) -> Unit,
onRefreshSessions: () -> Unit,
onSuccess: (() -> Unit)? = null,
onComplete: (() -> Unit)? = null
) {
scope.launch {
repository.sendMessage(sessionId, text, agent, model, attachments = attachments)
repository.sendMessage(sessionId, text, agent, model, attachments = attachments, messageId = messageId)
.onSuccess {
state.update {
it.copy(
inputText = "",
error = null,
sessions = bumpSessionUpdated(it.sessions, sessionId, System.currentTimeMillis()),
sessionStatuses = it.sessionStatuses + (sessionId to com.yage.opencode_client.data.model.SessionStatus(type = "busy"))
Expand All @@ -465,8 +486,67 @@ internal fun launchSendMessage(
}
}
.onFailure { error ->
state.update { it.copy(error = errorMessageOrFallback(error, "Failed to send message")) }
// The optimistic row was inserted before dispatch. On failure, drop it
// and hand the text/attachments back to the composer so the user can retry.
// Only restore the composer if the user is still on the session that sent
// this message; otherwise we'd clobber another session's draft.
state.update {
it.copy(
messages = it.messages.filter { m -> m.info.id != messageId },
pendingOptimisticMessageIds = it.pendingOptimisticMessageIds - messageId,
inputText = if (it.currentSessionId == sessionId) text else it.inputText,
imageAttachments = if (it.currentSessionId == sessionId) attachments else it.imageAttachments,
error = errorMessageOrFallback(error, "Failed to send message")
)
}
}
onComplete?.invoke()
}
}

internal fun makeServerId(prefix: String): String =
"${prefix}_${UUID.randomUUID().toString().replace("-", "")}"

internal fun buildOptimisticMessage(
sessionId: String,
text: String,
attachments: List<ComposerImageAttachment>,
messageId: String,
parentMessageId: String?
): MessageWithParts {
val now = System.currentTimeMillis()
val message = Message(
id = messageId,
sessionId = sessionId,
role = "user",
parentId = parentMessageId,
time = Message.TimeInfo(created = now, completed = now)
)
val parts = buildList {
if (text.isNotBlank()) {
add(
Part(
id = "temp-part-$messageId",
messageId = messageId,
sessionId = sessionId,
type = "text",
text = text
)
)
}
attachments.forEach { attachment ->
add(
Part(
id = "temp-file-${attachment.id}",
messageId = messageId,
sessionId = sessionId,
type = "file",
mime = attachment.mime,
filename = attachment.filename,
url = attachment.dataUrl
)
)
}
}
return MessageWithParts(info = message, parts = parts)
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import com.yage.opencode_client.data.model.SSEEvent
import com.yage.opencode_client.data.model.Session
import com.yage.opencode_client.data.model.SessionStatus
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import java.security.MessageDigest

Expand Down Expand Up @@ -169,6 +170,28 @@ internal fun parseQuestionAskedEvent(event: SSEEvent): QuestionRequest? {
}.getOrNull()
}

/**
* Builds a short display reason from a `session.error` payload
* (`error: {name, data: {message}}`). Server causes can be long multi-line
* dumps; keep the first meaningful line, bounded.
*/
internal fun parseSessionErrorReason(event: SSEEvent): String {
val errorObj = event.payload.getJsonObject("error") ?: return "unknown error"
val name = (errorObj["name"] as? JsonPrimitive)?.content
val data = errorObj["data"] as? JsonObject
val message = (data?.get("message") as? JsonPrimitive)?.content
?: (data?.get("error") as? JsonPrimitive)?.content
val text = when {
!message.isNullOrBlank() -> {
val firstLine = message.lineSequence().map { it.trim() }.firstOrNull { it.isNotEmpty() } ?: message
if (!name.isNullOrBlank()) "$name: $firstLine" else firstLine
}
!name.isNullOrBlank() -> name
else -> "unknown error"
}
return text.take(300)
}

internal fun reasoningPartOrNull(partType: String, partId: String, messageId: String, sessionId: String): Part? {
return if (partType == "reasoning") {
Part(id = partId, messageId = messageId, sessionId = sessionId, type = "reasoning")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -184,5 +184,34 @@ internal fun handleIncomingSseEvent(
}
state.update { it.copy(sessionTodos = it.sessionTodos + (sessionId to todos)) }
}
"session.error" -> {
val sessionId = event.payload.getString("sessionID")
if (sessionId != null && sessionId == state.value.currentSessionId) {
// prompt_async acknowledges with 204 before the turn runs, so a failure
// that happens before the user message is persisted only surfaces here.
// The server will never echo our id, so drop the pending optimistic rows
// and hand their text back to the composer so the user can retry.
val reason = parseSessionErrorReason(event)
state.update { state ->
if (state.pendingOptimisticMessageIds.isEmpty()) {
state.copy(error = "Send failed: $reason")
} else {
val pendingIds = state.pendingOptimisticMessageIds
val recoveredText = state.messages
.filter { m -> m.info.id in pendingIds }
.mapNotNull { row -> row.parts.firstOrNull { p -> p.isText }?.text }
.filter { text -> text.isNotBlank() }
.joinToString("\n")
state.copy(
messages = state.messages.filter { m -> m.info.id !in pendingIds },
pendingOptimisticMessageIds = emptySet(),
inputText = recoveredText.ifBlank { state.inputText },
error = "Send failed: $reason"
)
}
}
onRefreshMessages(sessionId, false)
}
}
}
}
Loading
Loading