From 7cef70286cfe02b2ded5e8d1e76a8dcb513783d0 Mon Sep 17 00:00:00 2001 From: Jasmin Le Roux Date: Sun, 5 Jul 2026 18:05:28 +0200 Subject: [PATCH 01/22] Fix permission prompts and route parsing edge cases --- .../p4oc/core/mime/FilenameMimeType.kt | 6 +- .../p4oc/data/files/FilePathValidator.kt | 4 + .../p4oc/data/remote/mapper/Mappers.kt | 62 ++++++----- .../data/session/SessionRepositoryImpl.kt | 13 ++- .../p4oc/domain/model/ToolStateExt.kt | 4 +- .../components/question/InlineQuestionCard.kt | 21 +++- .../p4oc/ui/navigation/TabChatRouteCodec.kt | 8 +- .../p4oc/ui/screens/chat/ChatScreen.kt | 3 + .../ui/screens/chat/DialogQueueManager.kt | 7 +- .../p4oc/core/mime/FilenameMimeTypeTest.kt | 6 ++ .../p4oc/data/files/FilePathValidatorTest.kt | 10 ++ .../p4oc/data/remote/mapper/MapperTests.kt | 55 ++++++++++ .../data/session/SessionRepositoryImplTest.kt | 35 ++++++ .../p4oc/domain/model/ToolStateExtTest.kt | 100 ++++++++++++++++++ .../question/InlineQuestionCardTest.kt | 63 +++++++++++ .../ui/navigation/TabChatRouteCodecTest.kt | 13 +++ .../ui/screens/chat/DialogQueueManagerTest.kt | 5 +- .../PendingPermissionAttentionVersionTest.kt | 36 +++++++ 18 files changed, 402 insertions(+), 49 deletions(-) create mode 100644 app/src/test/java/dev/blazelight/p4oc/domain/model/ToolStateExtTest.kt create mode 100644 app/src/test/java/dev/blazelight/p4oc/ui/components/question/InlineQuestionCardTest.kt create mode 100644 app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/PendingPermissionAttentionVersionTest.kt diff --git a/app/src/main/java/dev/blazelight/p4oc/core/mime/FilenameMimeType.kt b/app/src/main/java/dev/blazelight/p4oc/core/mime/FilenameMimeType.kt index 7b34dc46..af1eed83 100644 --- a/app/src/main/java/dev/blazelight/p4oc/core/mime/FilenameMimeType.kt +++ b/app/src/main/java/dev/blazelight/p4oc/core/mime/FilenameMimeType.kt @@ -13,8 +13,10 @@ object FilenameMimeType { internal fun resolve(name: String?, lookup: (String) -> String?): String? { if (name.isNullOrBlank()) return null - val extension = name.substringAfterLast('.', missingDelimiterValue = "").lowercase() - if (extension.isBlank()) return null + val filename = name.substringAfterLast('/') + val dotIndex = filename.lastIndexOf('.') + if (dotIndex <= 0 || dotIndex == filename.lastIndex) return null + val extension = filename.substring(dotIndex + 1).lowercase() return lookup(extension) } diff --git a/app/src/main/java/dev/blazelight/p4oc/data/files/FilePathValidator.kt b/app/src/main/java/dev/blazelight/p4oc/data/files/FilePathValidator.kt index f4544358..f3cc7a74 100644 --- a/app/src/main/java/dev/blazelight/p4oc/data/files/FilePathValidator.kt +++ b/app/src/main/java/dev/blazelight/p4oc/data/files/FilePathValidator.kt @@ -15,6 +15,10 @@ internal object FilePathValidator { return if (allowRoot) Result.success("") else invalid("Root file path is not allowed for mutations") } + if (!allowRoot && path != trimmed) { + return invalid("Leading or trailing whitespace is not allowed in file paths") + } + if (trimmed.startsWith("~")) { return invalid("Home-relative file paths are not allowed") } diff --git a/app/src/main/java/dev/blazelight/p4oc/data/remote/mapper/Mappers.kt b/app/src/main/java/dev/blazelight/p4oc/data/remote/mapper/Mappers.kt index f4700458..2577414f 100644 --- a/app/src/main/java/dev/blazelight/p4oc/data/remote/mapper/Mappers.kt +++ b/app/src/main/java/dev/blazelight/p4oc/data/remote/mapper/Mappers.kt @@ -11,6 +11,7 @@ import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.intOrNull // ============================================================================ // Project Mapper @@ -92,6 +93,13 @@ object SessionMapper { // Message Mapper // ============================================================================ +private fun JsonObject.stringValue(key: String): String? = (this[key] as? JsonPrimitive)?.contentOrNull + +private fun JsonObject.intValue(key: String): Int? = (this[key] as? JsonPrimitive)?.intOrNull + +private fun JsonObject.booleanValue(key: String): Boolean = + (this[key] as? JsonPrimitive)?.contentOrNull?.toBooleanStrictOrNull() ?: false + class MessageMapper constructor( private val json: Json ) { @@ -146,12 +154,12 @@ class MessageMapper constructor( val data = dto.data return MessageError( name = dto.name, - message = data?.get("message")?.toString()?.removeSurrounding("\""), - statusCode = data?.get("statusCode")?.toString()?.toIntOrNull(), - isRetryable = data?.get("isRetryable")?.toString()?.toBooleanStrictOrNull() ?: false, - providerID = data?.get("providerID")?.toString()?.removeSurrounding("\""), + message = data?.stringValue("message"), + statusCode = data?.intValue("statusCode"), + isRetryable = data?.booleanValue("isRetryable") ?: false, + providerID = data?.stringValue("providerID"), responseHeaders = null, - responseBody = data?.get("responseBody")?.toString()?.removeSurrounding("\"") + responseBody = data?.stringValue("responseBody") ) } @@ -159,10 +167,10 @@ class MessageMapper constructor( if (dto.name != "APIError") return null val data = dto.data ?: return null return ApiError( - message = data["message"]?.toString()?.removeSurrounding("\"") ?: "", - statusCode = data["statusCode"]?.toString()?.toIntOrNull(), - isRetryable = data["isRetryable"]?.toString()?.toBooleanStrictOrNull() ?: false, - responseBody = data["responseBody"]?.toString()?.removeSurrounding("\"") + message = data.stringValue("message") ?: "", + statusCode = data.intValue("statusCode"), + isRetryable = data.booleanValue("isRetryable"), + responseBody = data.stringValue("responseBody") ) } @@ -344,9 +352,9 @@ object PartMapper { ) private fun mapAgentSourceToDomain(source: JsonObject): AgentPartSource? { - val value = source["value"]?.toString()?.removeSurrounding("\"") ?: return null - val start = source["start"]?.toString()?.toIntOrNull() ?: return null - val end = source["end"]?.toString()?.toIntOrNull() ?: return null + val value = source.stringValue("value") ?: return null + val start = source.intValue("start") ?: return null + val end = source.intValue("end") ?: return null return AgentPartSource(value, start, end) } @@ -354,22 +362,22 @@ object PartMapper { if (dto.name != "APIError") return null val data = dto.data ?: return null return ApiError( - message = data["message"]?.toString()?.removeSurrounding("\"") ?: "", - statusCode = data["statusCode"]?.toString()?.toIntOrNull(), - isRetryable = data["isRetryable"]?.toString()?.toBooleanStrictOrNull() ?: false, - responseBody = data["responseBody"]?.toString()?.removeSurrounding("\"") + message = data.stringValue("message") ?: "", + statusCode = data.intValue("statusCode"), + isRetryable = data.booleanValue("isRetryable"), + responseBody = data.stringValue("responseBody") ) } private fun mapFileSourceToDomain(source: JsonObject): FilePartSource? { - val typeValue = source["type"]?.toString()?.removeSurrounding("\"") ?: return null + val typeValue = source.stringValue("type") ?: return null val textObj = source["text"] as? JsonObject ?: return null val text = FilePartSourceText( - value = textObj["value"]?.toString()?.removeSurrounding("\"") ?: "", - start = textObj["start"]?.toString()?.toIntOrNull() ?: 0, - end = textObj["end"]?.toString()?.toIntOrNull() ?: 0 + value = textObj.stringValue("value") ?: "", + start = textObj.intValue("start") ?: 0, + end = textObj.intValue("end") ?: 0 ) - val path = source["path"]?.toString()?.removeSurrounding("\"") ?: "" + val path = source.stringValue("path") ?: "" return when (typeValue) { "file" -> FilePartSource.FileSource(text = text, path = path) @@ -379,10 +387,10 @@ object PartMapper { val endObj = rangeObj?.get("end") as? JsonObject val range = if (startObj != null && endObj != null) { SymbolRange( - startLine = startObj["line"]?.toString()?.toIntOrNull() ?: 0, - startCharacter = startObj["character"]?.toString()?.toIntOrNull() ?: 0, - endLine = endObj["line"]?.toString()?.toIntOrNull() ?: 0, - endCharacter = endObj["character"]?.toString()?.toIntOrNull() ?: 0 + startLine = startObj.intValue("line") ?: 0, + startCharacter = startObj.intValue("character") ?: 0, + endLine = endObj.intValue("line") ?: 0, + endCharacter = endObj.intValue("character") ?: 0 ) } else { SymbolRange(0, 0, 0, 0) @@ -391,8 +399,8 @@ object PartMapper { text = text, path = path, range = range, - name = source["name"]?.toString()?.removeSurrounding("\"") ?: "", - kind = source["kind"]?.toString()?.toIntOrNull() ?: 0 + name = source.stringValue("name") ?: "", + kind = source.intValue("kind") ?: 0 ) } else -> null diff --git a/app/src/main/java/dev/blazelight/p4oc/data/session/SessionRepositoryImpl.kt b/app/src/main/java/dev/blazelight/p4oc/data/session/SessionRepositoryImpl.kt index ce072a1c..76dc16ae 100644 --- a/app/src/main/java/dev/blazelight/p4oc/data/session/SessionRepositoryImpl.kt +++ b/app/src/main/java/dev/blazelight/p4oc/data/session/SessionRepositoryImpl.kt @@ -17,6 +17,7 @@ import dev.blazelight.p4oc.domain.model.Message import dev.blazelight.p4oc.domain.model.MessageWithParts import dev.blazelight.p4oc.domain.model.OpenCodeEvent import dev.blazelight.p4oc.domain.model.Part +import dev.blazelight.p4oc.domain.model.Permission import dev.blazelight.p4oc.domain.model.Session import dev.blazelight.p4oc.domain.model.SessionStatus import dev.blazelight.p4oc.domain.model.TokenUsage @@ -35,7 +36,6 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.cancel import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -245,9 +245,9 @@ class SessionRepositoryImpl( } is OpenCodeEvent.PermissionRequested -> { updateOwnedSession(event.permission.sessionID) { state -> - val callId = event.permission.callID ?: return@updateOwnedSession state state.copy( - pendingPermissionsByCallId = state.pendingPermissionsByCallId + (callId to event.permission) + pendingPermissionsByCallId = state.pendingPermissionsByCallId + + (event.permission.pendingPermissionKey() to event.permission) ) } } @@ -708,10 +708,7 @@ class SessionRepositoryImpl( ?: return } updateSession(sessionId) { state -> - val recovered = permissions.mapNotNull { permission -> - val callId = permission.callID ?: return@mapNotNull null - callId to permission - }.toMap() + val recovered = permissions.associateBy { permission -> permission.pendingPermissionKey() } state.copy(pendingPermissionsByCallId = recovered) } } @@ -893,6 +890,8 @@ class SessionRepositoryImpl( } } + private fun Permission.pendingPermissionKey(): String = callID ?: "permission:$id" + private companion object { const val FRESHNESS_MS = 30_000L const val MAX_CONCURRENT = 10 diff --git a/app/src/main/java/dev/blazelight/p4oc/domain/model/ToolStateExt.kt b/app/src/main/java/dev/blazelight/p4oc/domain/model/ToolStateExt.kt index 99dec5e3..5928b284 100644 --- a/app/src/main/java/dev/blazelight/p4oc/domain/model/ToolStateExt.kt +++ b/app/src/main/java/dev/blazelight/p4oc/domain/model/ToolStateExt.kt @@ -24,6 +24,7 @@ private fun parseQuestion(json: JsonObject): Question? { val question = json["question"]?.jsonPrimitive?.content ?: return null val optionsArray = json["options"]?.jsonArray ?: return null val multiple = json["multiple"]?.jsonPrimitive?.booleanOrNull ?: false + val custom = json["custom"]?.jsonPrimitive?.booleanOrNull ?: true val options = optionsArray.mapNotNull { optionElement -> parseQuestionOption(optionElement as? JsonObject ?: return@mapNotNull null) @@ -35,7 +36,8 @@ private fun parseQuestion(json: JsonObject): Question? { header = header, question = question, options = options, - multiple = multiple + multiple = multiple, + custom = custom ) } catch (e: Exception) { null diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/components/question/InlineQuestionCard.kt b/app/src/main/java/dev/blazelight/p4oc/ui/components/question/InlineQuestionCard.kt index 85b955c9..3a63fb0e 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/components/question/InlineQuestionCard.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/components/question/InlineQuestionCard.kt @@ -55,7 +55,11 @@ fun InlineQuestionCard( val currentQuestion = questionData.questions.getOrNull(currentQuestionIndex) val isLastQuestion = currentQuestionIndex == questionData.questions.lastIndex - val hasAnswer = answers.getOrNull(currentQuestionIndex)?.isNotEmpty() == true + val sanitizedAnswers = currentQuestion?.let { q -> + val current = answers.getOrNull(currentQuestionIndex).orEmpty() + sanitizeAnswersForQuestion(q, current) + } ?: emptyList() + val hasAnswer = sanitizedAnswers.isNotEmpty() Column( modifier = modifier @@ -112,7 +116,7 @@ fun InlineQuestionCard( // Options InlineQuestionOptions( question = question, - selectedOptions = answers.getOrNull(currentQuestionIndex).orEmpty(), + selectedOptions = sanitizedAnswers, onSelectionChange = { selected -> answers = answers.mapIndexed { index, answer -> if (index == currentQuestionIndex) selected else answer @@ -164,6 +168,17 @@ fun InlineQuestionCard( } } +internal fun shouldShowCustomAnswerOption(question: Question): Boolean = question.custom + +internal fun sanitizeAnswersForQuestion( + question: Question, + selectedOptions: List +): List = if (question.custom) { + selectedOptions +} else { + selectedOptions.filter { it in question.options.map { o -> o.label }.toSet() } +} + @Composable private fun InlineQuestionOptions( question: Question, @@ -209,6 +224,8 @@ private fun InlineQuestionOptions( ) } + if (!shouldShowCustomAnswerOption(question)) return@Column + // Custom answer option if (showCustomInput) { OutlinedTextField( diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/navigation/TabChatRouteCodec.kt b/app/src/main/java/dev/blazelight/p4oc/ui/navigation/TabChatRouteCodec.kt index d19a1dd9..f41b9c03 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/navigation/TabChatRouteCodec.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/navigation/TabChatRouteCodec.kt @@ -28,8 +28,8 @@ object TabChatRouteCodec { if (segments.size != 4) return null if (segments[0] != "tab" || segments[2] != "chat") return null - val tabId = segments[1].routeDecode() - val sessionId = segments[3].routeDecode() + val tabId = segments[1].routeDecode() ?: return null + val sessionId = segments[3].routeDecode() ?: return null if (tabId.isBlank() || sessionId.isBlank()) return null return TabChatRoute(tabId, sessionId) @@ -40,4 +40,6 @@ private fun String.routeEncode(): String = URLEncoder .encode(this, StandardCharsets.UTF_8.name()) .replace("+", "%20") -private fun String.routeDecode(): String = URLDecoder.decode(this, StandardCharsets.UTF_8.name()) +private fun String.routeDecode(): String? = runCatching { + URLDecoder.decode(replace("+", "%2B"), StandardCharsets.UTF_8.name()) +}.getOrNull() diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatScreen.kt index 2c204ccd..d280b25f 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatScreen.kt @@ -53,6 +53,9 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel +internal fun pendingPermissionAttentionVersion(pendingPermissionCallIds: Set): String = + pendingPermissionCallIds.sorted().joinToString(separator = "\u001F") + @OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatScreen( diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/DialogQueueManager.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/DialogQueueManager.kt index 1d1f6d63..0ec85035 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/DialogQueueManager.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/DialogQueueManager.kt @@ -71,12 +71,11 @@ class DialogQueueManager( } fun enqueuePermission(permission: Permission) { - // Add to callID map for inline rendering - permission.callID?.let { callId -> - _pendingPermissionsByCallId.update { it + (callId to permission) } - } + _pendingPermissionsByCallId.update { it + (permission.pendingPermissionKey() to permission) } } + private fun Permission.pendingPermissionKey(): String = callID ?: "permission:$id" + fun setPermissionsByCallId(permissions: Map) { _pendingPermissionsByCallId.value = permissions } diff --git a/app/src/test/java/dev/blazelight/p4oc/core/mime/FilenameMimeTypeTest.kt b/app/src/test/java/dev/blazelight/p4oc/core/mime/FilenameMimeTypeTest.kt index 9e290e1f..7b889e71 100644 --- a/app/src/test/java/dev/blazelight/p4oc/core/mime/FilenameMimeTypeTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/core/mime/FilenameMimeTypeTest.kt @@ -34,6 +34,12 @@ class FilenameMimeTypeTest { assertNull(FilenameMimeType.resolve(" ", lookup)) } + @Test + fun `hidden dotfiles without basename extension return null`() { + assertNull(FilenameMimeType.resolve(".png", lookup)) + assertNull(FilenameMimeType.resolve(".env", lookup)) + } + @Test fun `octet stream fallback preserves chat attachment behavior`() { assertEquals(FilenameMimeType.OCTET_STREAM, FilenameMimeType.resolveOrOctetStream("README")) diff --git a/app/src/test/java/dev/blazelight/p4oc/data/files/FilePathValidatorTest.kt b/app/src/test/java/dev/blazelight/p4oc/data/files/FilePathValidatorTest.kt index f46953e6..37de478a 100644 --- a/app/src/test/java/dev/blazelight/p4oc/data/files/FilePathValidatorTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/data/files/FilePathValidatorTest.kt @@ -51,4 +51,14 @@ class FilePathValidatorTest { fun `mutation accepts safe relative paths`() { assertEquals("src/Main.kt", FilePathValidator.normalizeForMutation("src//./Main.kt").getOrThrow()) } + + @Test + fun `mutation rejects file paths with surrounding whitespace`() { + listOf(" file.txt", "file.txt ", "dir/ file.txt ").forEach { path -> + assertTrue( + "Expected path with surrounding whitespace to be rejected: <$path>", + FilePathValidator.normalizeForMutation(path).isFailure + ) + } + } } diff --git a/app/src/test/java/dev/blazelight/p4oc/data/remote/mapper/MapperTests.kt b/app/src/test/java/dev/blazelight/p4oc/data/remote/mapper/MapperTests.kt index 11bc9ce8..7b18aa26 100644 --- a/app/src/test/java/dev/blazelight/p4oc/data/remote/mapper/MapperTests.kt +++ b/app/src/test/java/dev/blazelight/p4oc/data/remote/mapper/MapperTests.kt @@ -225,6 +225,40 @@ class PartMapperTest { assertEquals("https://example.com/screenshot.png", filePart.url) } + @Test + fun `maps file source strings without json escapes`() { + val dto = PartDto( + id = "part-source", + sessionID = "sess-1", + messageID = "msg-1", + type = "file", + mime = "text/plain", + filename = "quoted.txt", + url = "file://quoted.txt", + source = buildJsonObject { + put("type", "file") + put("path", "src/\"quoted\".txt") + put( + "text", + buildJsonObject { + put("value", "hello \"world\"\nnext") + put("start", 1) + put("end", 2) + } + ) + } + ) + + val part = PartMapper.mapToDomain(dto) + + assertTrue(part is Part.File) + val source = (part as Part.File).source + assertTrue(source is FilePartSource.FileSource) + val fileSource = source as FilePartSource.FileSource + assertEquals("src/\"quoted\".txt", fileSource.path) + assertEquals("hello \"world\"\nnext", fileSource.text.value) + } + @Test fun `maps patch part`() { val dto = PartDto( @@ -347,6 +381,27 @@ class MessageMapperTest { assertEquals("end_turn", assistant.finish) } + @Test + fun `maps api error primitive strings without json escapes`() { + val dto = MessageErrorDto( + name = "APIError", + data = buildJsonObject { + put("message", "Bad \"request\"\nRetry") + put("statusCode", 429) + put("isRetryable", true) + put("responseBody", "{\"error\":\"slow down\"}") + } + ) + + val error = messageMapper.mapApiErrorToDomain(dto) + + assertNotNull(error) + assertEquals("Bad \"request\"\nRetry", error!!.message) + assertEquals(429, error.statusCode) + assertTrue(error.isRetryable) + assertEquals("{\"error\":\"slow down\"}", error.responseBody) + } + @Test fun `maps wrapper with parts`() { val wrapperDto = MessageWrapperDto( diff --git a/app/src/test/java/dev/blazelight/p4oc/data/session/SessionRepositoryImplTest.kt b/app/src/test/java/dev/blazelight/p4oc/data/session/SessionRepositoryImplTest.kt index c0821572..25cd285f 100644 --- a/app/src/test/java/dev/blazelight/p4oc/data/session/SessionRepositoryImplTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/data/session/SessionRepositoryImplTest.kt @@ -5,6 +5,7 @@ import dev.blazelight.p4oc.domain.model.Message import dev.blazelight.p4oc.domain.model.MessageError import dev.blazelight.p4oc.domain.model.OpenCodeEvent import dev.blazelight.p4oc.domain.model.Part +import dev.blazelight.p4oc.domain.model.Permission import dev.blazelight.p4oc.domain.model.Session import dev.blazelight.p4oc.domain.model.SessionStatus import dev.blazelight.p4oc.domain.model.TokenUsage @@ -19,6 +20,7 @@ import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonObject import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull @@ -507,6 +509,39 @@ class SessionRepositoryImplTest { assertFalse(text.isStreaming) } + @Test + fun `permission request without callID is still exposed in session UI state`() = runTest { + val client = FakeWorkspaceClient().apply { + projects = emptyList() + setSessions(FakeWorkspaceClient.sessionDto(id = "s1", title = "Session")) + } + val repository = + SessionRepositoryImpl( + client, + nowMs = { testScheduler.currentTime }, + dispatcher = StandardTestDispatcher(testScheduler) + ) + repository.refresh() + + val permission = Permission( + id = "per_1", + type = "bash", + patterns = listOf("ls -la"), + sessionID = "s1", + messageID = "msg-1", + callID = null, + metadata = JsonObject(emptyMap()), + always = emptyList() + ) + repository.acceptEvent(OpenCodeEvent.PermissionRequested(permission)) + + val uiState = repository.sessionUiState(SessionId("s1")).value + assertTrue( + "Permission without callID should still be visible in session state", + uiState.pendingPermissionsByCallId.values.any { it.id == "per_1" } + ) + } + private fun session(id: String): Session = Session( id = id, projectID = "project-$id", diff --git a/app/src/test/java/dev/blazelight/p4oc/domain/model/ToolStateExtTest.kt b/app/src/test/java/dev/blazelight/p4oc/domain/model/ToolStateExtTest.kt new file mode 100644 index 00000000..caed6105 --- /dev/null +++ b/app/src/test/java/dev/blazelight/p4oc/domain/model/ToolStateExtTest.kt @@ -0,0 +1,100 @@ +package dev.blazelight.p4oc.domain.model + +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class ToolStateExtTest { + + @Test + fun `question data preserves custom=false flag`() { + val input = buildJsonObject { + putJsonArray("questions") { + add( + buildJsonObject { + put("header", "Pick option") + put("question", "Which?") + put("custom", false) + putJsonArray("options") { + add( + buildJsonObject { + put("label", "A") + put("description", "desc-a") + } + ) + add( + buildJsonObject { + put("label", "B") + put("description", "desc-b") + } + ) + } + } + ) + } + } + val pending = ToolState.Pending(input = input, rawInput = "") + + val data = pending.asQuestionData() + + assertNotNull(data) + assertEquals(1, data!!.questions.size) + val q = data.questions[0] + assertEquals("Pick option", q.header) + assertEquals("Which?", q.question) + assertEquals(2, q.options.size) + assertEquals("A", q.options[0].label) + assertEquals("desc-a", q.options[0].description) + assertFalse("custom flag must be preserved as false", q.custom) + } + + @Test + fun `question data defaults custom to true when absent`() { + val input = buildJsonObject { + putJsonArray("questions") { + add( + buildJsonObject { + put("header", "Pick") + put("question", "Which?") + putJsonArray("options") { + add( + buildJsonObject { + put("label", "A") + put("description", "desc-a") + } + ) + } + } + ) + } + } + val pending = ToolState.Pending(input = input, rawInput = "") + + val data = pending.asQuestionData() + + assertNotNull(data) + assertTrue(data!!.questions[0].custom) + } + + @Test + fun `isQuestionTool returns true only for question tool`() { + val questionTool = Part.Tool( + id = "p1", + sessionID = "s1", + messageID = "m1", + callID = "c1", + toolName = "question", + state = ToolState.Pending(buildJsonObject {}, "") + ) + val otherTool = questionTool.copy(toolName = "bash") + + assertTrue(questionTool.isQuestionTool()) + assertFalse(otherTool.isQuestionTool()) + } +} diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/components/question/InlineQuestionCardTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/components/question/InlineQuestionCardTest.kt new file mode 100644 index 00000000..f7839795 --- /dev/null +++ b/app/src/test/java/dev/blazelight/p4oc/ui/components/question/InlineQuestionCardTest.kt @@ -0,0 +1,63 @@ +package dev.blazelight.p4oc.ui.components.question + +import dev.blazelight.p4oc.domain.model.Question +import dev.blazelight.p4oc.domain.model.QuestionOption +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class InlineQuestionCardTest { + + @Test + fun `custom answer option is hidden when question disallows custom answers`() { + val question = Question( + header = "Pick", + question = "Choose one", + options = listOf(QuestionOption("A", "Option A")), + custom = false + ) + + assertFalse(shouldShowCustomAnswerOption(question)) + } + + @Test + fun `custom answer option is shown when question allows custom answers`() { + val question = Question( + header = "Pick", + question = "Choose one", + options = listOf(QuestionOption("A", "Option A")), + custom = true + ) + + assertTrue(shouldShowCustomAnswerOption(question)) + } + + @Test + fun `sanitizeAnswersForQuestion strips stale custom answers when custom is false`() { + val question = Question( + header = "Pick", + question = "Choose one", + options = listOf(QuestionOption("A", "Option A"), QuestionOption("B", "Option B")), + custom = false + ) + + val sanitized = sanitizeAnswersForQuestion(question, listOf("A", "custom text")) + + assertEquals(listOf("A"), sanitized) + } + + @Test + fun `sanitizeAnswersForQuestion preserves custom answers when custom is true`() { + val question = Question( + header = "Pick", + question = "Choose one", + options = listOf(QuestionOption("A", "Option A")), + custom = true + ) + + val sanitized = sanitizeAnswersForQuestion(question, listOf("A", "custom text")) + + assertEquals(listOf("A", "custom text"), sanitized) + } +} diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/navigation/TabChatRouteCodecTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/navigation/TabChatRouteCodecTest.kt index 6ac14d68..8150519c 100644 --- a/app/src/test/java/dev/blazelight/p4oc/ui/navigation/TabChatRouteCodecTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/ui/navigation/TabChatRouteCodecTest.kt @@ -32,4 +32,17 @@ class TabChatRouteCodecTest { assertNull(TabChatRouteCodec.decode("chat/session?directory=/repo")) assertNull(TabChatRouteCodec.decode("tab/%20/chat/session")) } + + @Test + fun `tab chat route decode preserves literal plus signs`() { + assertEquals( + TabChatRoute(tabId = "tab+one", sessionId = "session+two"), + TabChatRouteCodec.decode("tab/tab+one/chat/session+two") + ) + } + + @Test + fun `tab chat route decode rejects malformed percent escapes`() { + assertNull(TabChatRouteCodec.decode("tab/tab%ZZ/chat/session")) + } } diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/DialogQueueManagerTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/DialogQueueManagerTest.kt index 29daf0e8..3a52b598 100644 --- a/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/DialogQueueManagerTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/DialogQueueManagerTest.kt @@ -19,7 +19,6 @@ import kotlinx.serialization.json.buildJsonObject import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -53,14 +52,14 @@ class DialogQueueManagerTest { } @Test - fun enqueuePermission_withNullCallIdDoesNotAddAnything() { + fun enqueuePermission_withNullCallIdAddsPermissionBySyntheticKey() { val handle = SavedStateHandle() val manager = manager(handle) val permission = permission(id = "p1", callId = null) manager.enqueuePermission(permission) - assertTrue(manager.pendingPermissionsByCallId.value.isEmpty()) + assertEquals(permission, manager.pendingPermissionsByCallId.value["permission:p1"]) } @Test diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/PendingPermissionAttentionVersionTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/PendingPermissionAttentionVersionTest.kt new file mode 100644 index 00000000..9687c187 --- /dev/null +++ b/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/PendingPermissionAttentionVersionTest.kt @@ -0,0 +1,36 @@ +package dev.blazelight.p4oc.ui.screens.chat + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test + +class PendingPermissionAttentionVersionTest { + + @Test + fun `permission arrival changes the attention version`() { + val before = pendingPermissionAttentionVersion(emptySet()) + val after = pendingPermissionAttentionVersion(setOf("call_abc")) + + assertNotEquals( + "Pending permission arrival must change the tail-attention version so the chat scrolls to show it", + before, + after + ) + } + + @Test + fun `permission resolution changes the attention version`() { + val pending = pendingPermissionAttentionVersion(setOf("call_abc")) + val resolved = pendingPermissionAttentionVersion(emptySet()) + + assertNotEquals("Resolving a permission must change the version", pending, resolved) + } + + @Test + fun `same permissions produce same version`() { + val a = pendingPermissionAttentionVersion(setOf("call_abc", "call_def")) + val b = pendingPermissionAttentionVersion(setOf("call_def", "call_abc")) + + assertEquals("Same permission set must produce same version regardless of order", a, b) + } +} From 26a2b1e76136953a0a3fecd42480424be54130ea Mon Sep 17 00:00:00 2001 From: Jasmin Le Roux Date: Mon, 6 Jul 2026 13:22:53 +0200 Subject: [PATCH 02/22] Fix model defaults and chat restoration regressions --- app/detekt-baseline.xml | 2 +- .../dev/blazelight/p4oc/di/KoinModules.kt | 9 +- .../p4oc/ui/screens/chat/ChatScreen.kt | 93 ++++---- .../chat/ChatScrollRestorationState.kt | 88 ++++++++ .../p4oc/ui/screens/chat/ChatViewModel.kt | 77 ++++++- .../p4oc/ui/screens/chat/ModelAgentManager.kt | 58 +++-- .../screens/chat/ModelSelectionCoordinator.kt | 18 ++ .../screens/settings/ModelControlsScreen.kt | 33 ++- .../settings/ProviderConfigViewModel.kt | 20 +- .../screens/chat/ChatScrollRestorationTest.kt | 98 +++++++++ .../p4oc/ui/screens/chat/ChatViewModelTest.kt | 164 +++++++++++++- .../ui/screens/chat/ModelAgentManagerTest.kt | 204 +++++++++++++++++- .../settings/ModelControlsViewModelTest.kt | 189 ++++++++++++++++ .../settings/ProviderConfigViewModelTest.kt | 124 +++++++++++ 14 files changed, 1097 insertions(+), 80 deletions(-) create mode 100644 app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatScrollRestorationState.kt create mode 100644 app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ModelSelectionCoordinator.kt create mode 100644 app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/ChatScrollRestorationTest.kt create mode 100644 app/src/test/java/dev/blazelight/p4oc/ui/screens/settings/ModelControlsViewModelTest.kt create mode 100644 app/src/test/java/dev/blazelight/p4oc/ui/screens/settings/ProviderConfigViewModelTest.kt diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml index 54329679..5fa279e5 100644 --- a/app/detekt-baseline.xml +++ b/app/detekt-baseline.xml @@ -357,7 +357,7 @@ LongParameterList:ChatScreen.kt$( viewModel: ChatViewModel = koinViewModel(), onNavigateBack: () -> Unit, onOpenTerminal: () -> Unit, onOpenFiles: () -> Unit, onViewSessionDiff: ((String) -> Unit)? = null, onOpenSubSession: ((String) -> Unit)? = null, onSessionLoaded: ((sessionId: String, sessionTitle: String) -> Unit)? = null, onConnectionStateChanged: ((SessionConnectionState?) -> Unit)? = null, isActiveTab: Boolean = true ) LongParameterList:ChatSearchBar.kt$( glyph: String, contentDescription: String, testTag: String, enabled: Boolean, onClick: () -> Unit, color: Color, mutedColor: Color, ) LongParameterList:ChatSearchBar.kt$( query: String, onQueryChange: (String) -> Unit, matchCount: Int, currentIndex: Int, onPrev: () -> Unit, onNext: () -> Unit, onClose: () -> Unit, modifier: Modifier = Modifier, ) - LongParameterList:ChatViewModel.kt$ChatViewModel$( private val savedStateHandle: SavedStateHandle, private val workspaceClient: WorkspaceClient, private val sessionRepository: SessionRepositoryImpl, private val uploadCoordinator: UploadCoordinator, private val connectionManager: ConnectionManager, private val settingsDataStore: SettingsDataStore, private val hapticFeedback: HapticFeedback, ) + LongParameterList:ChatViewModel.kt$ChatViewModel$( private val savedStateHandle: SavedStateHandle, private val workspaceClient: WorkspaceClient, private val sessionRepository: SessionRepositoryImpl, private val uploadCoordinator: UploadCoordinator, private val connectionManager: ConnectionManager, private val settingsDataStore: SettingsDataStore, private val hapticFeedback: HapticFeedback, private val modelSelectionCoordinator: ModelSelectionCoordinator = ModelSelectionCoordinator() ) LongParameterList:CommandPalette.kt$( commands: List<Command>, isLoading: Boolean, error: String?, onRetry: () -> Unit, onCommandSelected: (Command, String) -> Unit, onDismiss: () -> Unit ) LongParameterList:CommandPalette.kt$( searchQuery: String, onSearchChange: (String) -> Unit, filteredCommands: List<Command>, isLoading: Boolean, error: String?, onRetry: () -> Unit, onCommandClick: (Command) -> Unit, focusRequester: FocusRequester ) LongParameterList:ConnectionSettingsScreen.kt$( title: String, subtitle: String, icon: ImageVector, checked: Boolean, onCheckedChange: (Boolean) -> Unit, enabled: Boolean, testTag: String ) diff --git a/app/src/main/java/dev/blazelight/p4oc/di/KoinModules.kt b/app/src/main/java/dev/blazelight/p4oc/di/KoinModules.kt index 81ed016a..9938683d 100644 --- a/app/src/main/java/dev/blazelight/p4oc/di/KoinModules.kt +++ b/app/src/main/java/dev/blazelight/p4oc/di/KoinModules.kt @@ -17,6 +17,7 @@ import dev.blazelight.p4oc.data.session.SessionRepositoryImpl import dev.blazelight.p4oc.data.session.SessionRepositoryProvider import dev.blazelight.p4oc.domain.server.ServerRef import dev.blazelight.p4oc.ui.screens.chat.ChatViewModel +import dev.blazelight.p4oc.ui.screens.chat.ModelSelectionCoordinator import dev.blazelight.p4oc.ui.screens.files.FilesViewModel import dev.blazelight.p4oc.ui.screens.licenses.LicensesViewModel import dev.blazelight.p4oc.ui.screens.projects.ProjectsViewModel @@ -63,6 +64,7 @@ val appModule = module { // Tab management (singleton for app lifetime) single { TabManager() } + single { ModelSelectionCoordinator() } } val networkModule = module { @@ -99,7 +101,7 @@ val networkModule = module { val viewModelModule = module { viewModelOf(::ServerViewModel) - viewModelOf(::ModelControlsViewModel) + viewModel { ModelControlsViewModel(get(), get()) } viewModelOf(::AgentsConfigViewModel) viewModelOf(::VisualSettingsViewModel) viewModelOf(::ChatSettingsViewModel) @@ -107,7 +109,7 @@ val viewModelModule = module { viewModelOf(::SettingsViewModel) viewModelOf(::NotificationSettingsViewModel) viewModelOf(::LicensesViewModel) - viewModelOf(::ProviderConfigViewModel) + viewModel { ProviderConfigViewModel(get(), get()) } viewModelOf(::ProjectsViewModel) viewModel { params -> WorkspaceViewModel(params.get()) @@ -120,7 +122,8 @@ val viewModelModule = module { params.get(), get(), get(), - get() + get(), + get() ) } viewModel { params -> SessionListViewModel(params.get()) } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatScreen.kt index d280b25f..dcfb372d 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatScreen.kt @@ -9,9 +9,9 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape @@ -135,23 +135,20 @@ fun ChatScreen( ToolWidgetState.fromString(visualSettings.toolWidgetDefaultState) } - val listState = rememberLazyListState() + val listState = rememberSaveable(uiState.session?.id, saver = LazyListState.Saver) { LazyListState() } var showCommandPalette by remember { mutableStateOf(false) } var showTodoTracker by remember { mutableStateOf(false) } var showFilePicker by remember { mutableStateOf(false) } var showRevertDialog by remember { mutableStateOf(null) } - // In-chat search: query + current hit, reset whenever the open session changes. - var showSearch by remember(uiState.session?.id) { mutableStateOf(false) } - var searchQuery by remember(uiState.session?.id) { mutableStateOf("") } - var currentMatchIndex by remember(uiState.session?.id) { mutableStateOf(0) } + val scrollRestorationState = rememberSaveable( + uiState.session?.id, + saver = ChatScrollRestorationState.Saver + ) { ChatScrollRestorationState() } val messageBlocks = remember(messages) { groupMessagesIntoBlocks(messages) } - val searchMatches = remember(messageBlocks, searchQuery) { findChatMatches(messageBlocks, searchQuery) } - - // Scroll UX state: follow new tail content only while the user remains pinned to bottom. - var shouldFollowTail by remember(uiState.session?.id) { mutableStateOf(true) } - var didInitialTailScroll by remember(uiState.session?.id) { mutableStateOf(false) } - var hasNewContentWhileAway by remember(uiState.session?.id) { mutableStateOf(false) } + val searchMatches = remember(messageBlocks, scrollRestorationState.searchQuery) { + findChatMatches(messageBlocks, scrollRestorationState.searchQuery) + } val coroutineScope = rememberCoroutineScope() // Derived state: check if the bottom edge of the last rendered item is visible. @@ -174,9 +171,9 @@ fun ChatScreen( val keyboardController = LocalSoftwareKeyboardController.current BackHandler { - if (showSearch) { - showSearch = false - searchQuery = "" + if (scrollRestorationState.showSearch) { + scrollRestorationState.showSearch = false + scrollRestorationState.searchQuery = "" } else { focusManager.clearFocus() keyboardController?.hide() @@ -189,8 +186,7 @@ fun ChatScreen( snapshotFlow { listState.isScrollInProgress } .collect { isScrolling -> if (!isScrolling) { - shouldFollowTail = isAtBottom - if (isAtBottom) hasNewContentWhileAway = false + scrollRestorationState.onScrollSettled(isAtBottom) } } } @@ -210,32 +206,35 @@ fun ChatScreen( // Scroll on new messages, new parts, or streaming text/reasoning growth. LaunchedEffect(messageCount, tailContentVersion, isBusy, pendingQuestionId) { - if (didInitialTailScroll && (messages.isNotEmpty() || pendingQuestionId != null)) { - if (shouldFollowTail) { - listState.scrollChatToBottom() - } else { - hasNewContentWhileAway = true - } + if (scrollRestorationState.onTailContentChanged(messages.isNotEmpty() || pendingQuestionId != null)) { + listState.scrollChatToBottom() } } // Keep the active hit in range when matches change, and scroll it into view. LaunchedEffect(searchMatches.size) { - if (currentMatchIndex >= searchMatches.size) currentMatchIndex = 0 + if (scrollRestorationState.currentMatchIndex >= searchMatches.size) { + scrollRestorationState.currentMatchIndex = 0 + } } - LaunchedEffect(currentMatchIndex, searchMatches) { - searchMatches.getOrNull(currentMatchIndex)?.let { match -> - shouldFollowTail = false + LaunchedEffect(scrollRestorationState.currentMatchIndex, searchMatches) { + searchMatches.getOrNull(scrollRestorationState.currentMatchIndex)?.let { match -> + scrollRestorationState.shouldFollowTail = false listState.scrollToItem(match.blockIndex) } } // The loading screen hides the list; once the session content is visible, land at the tail. LaunchedEffect(uiState.session?.id, uiState.isLoading, messageCount, pendingQuestionId) { - if (!didInitialTailScroll && !uiState.isLoading && (messages.isNotEmpty() || pendingQuestionId != null)) { - snapshotFlow { listState.layoutInfo.totalItemsCount }.first { it > 0 } - if (shouldFollowTail) listState.scrollChatToBottom() - didInitialTailScroll = true + val hasRenderableTail = !uiState.isLoading && + (messages.isNotEmpty() || pendingQuestionId != null) + when (scrollRestorationState.onContentReady(hasRenderableTail)) { + InitialTailDecision.ScrollToTail -> { + snapshotFlow { listState.layoutInfo.totalItemsCount }.first { it > 0 } + listState.scrollChatToBottom() + } + InitialTailDecision.KeepRestoredPosition, + InitialTailDecision.NoContent -> Unit } } @@ -248,8 +247,8 @@ fun ChatScreen( onTerminal = onOpenTerminal, onFiles = onOpenFiles, onSearch = { - showSearch = true - currentMatchIndex = 0 + scrollRestorationState.showSearch = true + scrollRestorationState.currentMatchIndex = 0 }, onCommands = { viewModel.refreshCommandsIfNeeded(force = true) @@ -330,25 +329,27 @@ fun ChatScreen( .fillMaxSize() .padding(padding) ) { - if (showSearch) { + if (scrollRestorationState.showSearch) { ChatSearchBar( - query = searchQuery, - onQueryChange = { searchQuery = it }, + query = scrollRestorationState.searchQuery, + onQueryChange = { scrollRestorationState.searchQuery = it }, matchCount = searchMatches.size, - currentIndex = currentMatchIndex, + currentIndex = scrollRestorationState.currentMatchIndex, onPrev = { if (searchMatches.isNotEmpty()) { - currentMatchIndex = (currentMatchIndex - 1 + searchMatches.size) % searchMatches.size + scrollRestorationState.currentMatchIndex = + (scrollRestorationState.currentMatchIndex - 1 + searchMatches.size) % searchMatches.size } }, onNext = { if (searchMatches.isNotEmpty()) { - currentMatchIndex = (currentMatchIndex + 1) % searchMatches.size + scrollRestorationState.currentMatchIndex = + (scrollRestorationState.currentMatchIndex + 1) % searchMatches.size } }, onClose = { - showSearch = false - searchQuery = "" + scrollRestorationState.showSearch = false + scrollRestorationState.searchQuery = "" }, ) } @@ -407,8 +408,9 @@ fun ChatScreen( } } ) { index, block -> - val isCurrentMatch = showSearch && searchQuery.isNotBlank() && - searchMatches.getOrNull(currentMatchIndex)?.blockIndex == index + val isCurrentMatch = scrollRestorationState.showSearch && + scrollRestorationState.searchQuery.isNotBlank() && + searchMatches.getOrNull(scrollRestorationState.currentMatchIndex)?.blockIndex == index val highlight = if (isCurrentMatch) { Modifier.background(LocalOpenCodeTheme.current.accent.copy(alpha = 0.08f)) } else { @@ -473,11 +475,10 @@ fun ChatScreen( // Jump to bottom button - shows when scrolled away from the tail. JumpToBottomButton( visible = !isAtBottom, - hasNewContent = hasNewContentWhileAway, + hasNewContent = scrollRestorationState.hasNewContentWhileAway, onClick = { coroutineScope.launch { - shouldFollowTail = true - hasNewContentWhileAway = false + scrollRestorationState.onJumpToBottom() listState.scrollChatToBottom() } }, diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatScrollRestorationState.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatScrollRestorationState.kt new file mode 100644 index 00000000..3f7893e5 --- /dev/null +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatScrollRestorationState.kt @@ -0,0 +1,88 @@ +package dev.blazelight.p4oc.ui.screens.chat + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.setValue + +internal enum class InitialTailDecision { + ScrollToTail, + KeepRestoredPosition, + NoContent +} + +internal class ChatScrollRestorationState( + shouldFollowTail: Boolean = true, + didInitialTailScroll: Boolean = false, + hasNewContentWhileAway: Boolean = false, + showSearch: Boolean = false, + searchQuery: String = "", + currentMatchIndex: Int = 0, +) { + var shouldFollowTail by mutableStateOf(shouldFollowTail) + var didInitialTailScroll by mutableStateOf(didInitialTailScroll) + var hasNewContentWhileAway by mutableStateOf(hasNewContentWhileAway) + var showSearch by mutableStateOf(showSearch) + var searchQuery by mutableStateOf(searchQuery) + var currentMatchIndex by mutableIntStateOf(currentMatchIndex) + + fun onScrollSettled(isAtBottom: Boolean) { + shouldFollowTail = isAtBottom + if (isAtBottom) { + hasNewContentWhileAway = false + } + } + + fun onTailContentChanged(hasRenderableTail: Boolean): Boolean { + val shouldScrollToTail = didInitialTailScroll && hasRenderableTail && shouldFollowTail + if (didInitialTailScroll && hasRenderableTail && !shouldFollowTail) { + hasNewContentWhileAway = true + } + return shouldScrollToTail + } + + fun onJumpToBottom() { + shouldFollowTail = true + hasNewContentWhileAway = false + } + + fun onContentReady(hasRenderableTail: Boolean): InitialTailDecision { + val decision = when { + !hasRenderableTail -> InitialTailDecision.NoContent + didInitialTailScroll -> InitialTailDecision.KeepRestoredPosition + shouldFollowTail -> InitialTailDecision.ScrollToTail + else -> InitialTailDecision.KeepRestoredPosition + } + if (hasRenderableTail && !didInitialTailScroll) { + didInitialTailScroll = true + } + return decision + } + + companion object { + val Saver: Saver = listSaver( + save = { + listOf( + it.shouldFollowTail, + it.didInitialTailScroll, + it.hasNewContentWhileAway, + it.showSearch, + it.searchQuery, + it.currentMatchIndex, + ) + }, + restore = { + ChatScrollRestorationState( + shouldFollowTail = it[0] as Boolean, + didInitialTailScroll = it[1] as Boolean, + hasNewContentWhileAway = it[2] as Boolean, + showSearch = it[3] as Boolean, + searchQuery = it[4] as String, + currentMatchIndex = it[5] as Int, + ) + } + ) + } +} diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModel.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModel.kt index 243d1a4c..08473bbb 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModel.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModel.kt @@ -18,6 +18,7 @@ import dev.blazelight.p4oc.data.remote.dto.ModelInput import dev.blazelight.p4oc.data.remote.dto.PartInputDto import dev.blazelight.p4oc.data.remote.dto.PermissionResponseRequest import dev.blazelight.p4oc.data.remote.dto.QuestionReplyRequest +import dev.blazelight.p4oc.data.remote.dto.RevertSessionRequest import dev.blazelight.p4oc.data.remote.dto.SendMessageRequest import dev.blazelight.p4oc.data.remote.mapper.CommandMapper import dev.blazelight.p4oc.data.remote.mapper.SessionMapper @@ -51,8 +52,8 @@ class ChatViewModel constructor( private val connectionManager: ConnectionManager, private val settingsDataStore: SettingsDataStore, private val hapticFeedback: HapticFeedback, + private val modelSelectionCoordinator: ModelSelectionCoordinator = ModelSelectionCoordinator() ) : ViewModel() { - private val sessionId: String = savedStateHandle.get(Screen.Chat.ARG_SESSION_ID) ?: throw IllegalArgumentException("sessionId is required for ChatViewModel") @@ -61,7 +62,13 @@ class ChatViewModel constructor( // --- Sub-managers --- val dialogManager = DialogQueueManager(savedStateHandle, json, viewModelScope) - val modelAgentManager = ModelAgentManager(connectionManager, settingsDataStore, viewModelScope, sessionId) + val modelAgentManager = ModelAgentManager( + connectionManager, + settingsDataStore, + viewModelScope, + sessionId, + modelSelectionCoordinator + ) val filePickerManager = FilePickerManager(workspaceClient, viewModelScope, uploadCoordinator, settingsDataStore) // --- Core state --- @@ -561,6 +568,14 @@ class ChatViewModel constructor( } fun executeCommand(commandName: String, arguments: String) { + when (commandName.trim().lowercase()) { + "undo" -> undoSessionCommand() + "redo" -> redoSessionCommand() + else -> executeServerCommand(commandName, arguments) + } + } + + private fun executeServerCommand(commandName: String, arguments: String) { viewModelScope.launch { _uiState.update { it.copy(isSending = true) } val request = ExecuteCommandRequest( @@ -601,9 +616,65 @@ class ChatViewModel constructor( // --- Revert / Unrevert --- + private fun undoSessionCommand() { + val targetMessageId = previousUserMessageBoundary() + if (targetMessageId == null) { + _uiState.update { it.copy(error = "Nothing to undo") } + return + } + revertSessionTo(targetMessageId, "undo") + } + + private fun redoSessionCommand() { + val targetMessageId = nextUserMessageBoundary() + if (targetMessageId == null) { + _uiState.update { it.copy(error = "Nothing to redo") } + return + } + revertSessionTo(targetMessageId, "redo") + } + + private fun previousUserMessageBoundary(): String? { + val userMessages = orderedUserMessages() + val activeRevertIndex = activeRevertIndex(userMessages) ?: userMessages.size + return userMessages.getOrNull(activeRevertIndex - 1)?.id + } + + private fun nextUserMessageBoundary(): String? { + val userMessages = orderedUserMessages() + val activeRevertIndex = activeRevertIndex(userMessages) ?: return null + return userMessages.getOrNull(activeRevertIndex + 1)?.id + } + + private fun orderedUserMessages(): List = messages.value + .mapNotNull { it.message as? Message.User } + .sortedBy { it.createdAt } + + private fun activeRevertIndex(userMessages: List): Int? { + val activeRevertMessageId = _uiState.value.session?.revert?.messageID ?: return null + return userMessages.indexOfFirst { it.id == activeRevertMessageId }.takeIf { it >= 0 } + } + + private fun revertSessionTo(messageId: String, action: String) { + viewModelScope.launch { + _uiState.update { it.copy(isSending = true) } + val request = RevertSessionRequest(messageID = messageId) + val result = safeApiCall { workspaceClient.revertSession(sessionId, request) } + when (result) { + is ApiResult.Success -> { + _uiState.update { it.copy(isSending = false) } + loadSession() + } + is ApiResult.Error -> { + _uiState.update { it.copy(isSending = false, error = "Failed to $action: ${result.message}") } + } + } + } + } + fun revertMessage(messageId: String) { viewModelScope.launch { - val request = dev.blazelight.p4oc.data.remote.dto.RevertSessionRequest(messageID = messageId) + val request = RevertSessionRequest(messageID = messageId) val result = safeApiCall { workspaceClient.revertSession(sessionId, request) } when (result) { is ApiResult.Success -> { diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ModelAgentManager.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ModelAgentManager.kt index cc6a6b5f..35ffda56 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ModelAgentManager.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ModelAgentManager.kt @@ -24,7 +24,8 @@ class ModelAgentManager( private val connectionManager: ConnectionManager, private val settingsDataStore: SettingsDataStore, private val scope: CoroutineScope, - private val sessionId: String? = null + private val sessionId: String? = null, + modelSelectionCoordinator: ModelSelectionCoordinator? = null ) { private val _availableAgents = MutableStateFlow>(emptyList()) val availableAgents: StateFlow> = _availableAgents.asStateFlow() @@ -42,6 +43,7 @@ class ModelAgentManager( val selectedReasoningEffort: StateFlow = _selectedReasoningEffort.asStateFlow() private var selectedModelFromAgent = false + private var selectedModelExplicitly = false val favoriteModels: StateFlow> = settingsDataStore.favoriteModels .stateIn(scope, SharingStarted.Eagerly, emptySet()) @@ -49,6 +51,16 @@ class ModelAgentManager( val recentModels: StateFlow> = settingsDataStore.recentModels .stateIn(scope, SharingStarted.Eagerly, emptyList()) + init { + modelSelectionCoordinator?.let { coordinator -> + scope.launch { + coordinator.activeModelChanges.collect { model -> + reconcileActiveModel(model) + } + } + } + } + fun loadAgents() { scope.launch { val api = connectionManager.getApi() ?: run { @@ -67,8 +79,7 @@ class ModelAgentManager( val persistedAgent = sessionId?.let { settingsDataStore.getSelectedAgentForSession(it) } val selectedAgent = persistedAgent?.let { agentName -> primaryAgents.find { it.name == agentName } - } ?: primaryAgents.find { it.name == "build" } - ?: primaryAgents.firstOrNull() + } ?: primaryAgents.firstOrNull() selectedAgent?.name?.let { selectAgent(it, persist = false) } } is ApiResult.Error -> { @@ -97,6 +108,7 @@ class ModelAgentManager( modelID = agentModel.modelID ) selectedModelFromAgent = true + selectedModelExplicitly = false } fun loadModels() { @@ -112,21 +124,28 @@ class ModelAgentManager( models.add(providerId to model) } } - val defaultModel = result.data.default.entries.firstOrNull()?.let { (provider, modelId) -> - ModelInput(providerID = provider, modelID = modelId) - } - val lastUsedModel = recentModels.value.firstOrNull() - val selectedModel = if (lastUsedModel != null && models.any { - it.first == lastUsedModel.providerID && it.second.id == lastUsedModel.modelID + val defaultModel = result.data.default.entries + .map { (provider, modelId) -> ModelInput(providerID = provider, modelID = modelId) } + .firstOrNull { candidate -> + models.any { (providerId, model) -> + providerId == candidate.providerID && model.id == candidate.modelID + } + } + val lastUsedModel = recentModels.value.firstOrNull { candidate -> + models.any { (providerId, model) -> + providerId == candidate.providerID && model.id == candidate.modelID } - ) { - lastUsedModel - } else { - defaultModel } + val fallbackModel = defaultModel ?: lastUsedModel + val currentSelectionIsAvailable = _selectedModel.value?.let { selected -> + models.any { (providerId, model) -> + providerId == selected.providerID && model.id == selected.modelID + } + } == true _availableModels.value = models - if (!selectedModelFromAgent) { - _selectedModel.value = selectedModel + if (!selectedModelFromAgent && (!selectedModelExplicitly || !currentSelectionIsAvailable)) { + _selectedModel.value = fallbackModel + selectedModelExplicitly = false } } is ApiResult.Error -> {} @@ -140,6 +159,7 @@ class ModelAgentManager( } _selectedModel.value = model selectedModelFromAgent = false + selectedModelExplicitly = true scope.launch { settingsDataStore.addRecentModel(model) } @@ -166,6 +186,14 @@ class ModelAgentManager( } } + private fun reconcileActiveModel(model: ModelInput) { + if (selectedModelFromAgent || selectedModelExplicitly) return + if (_selectedModel.value != model) { + _selectedReasoningEffort.value = null + } + _selectedModel.value = model + } + private companion object { const val TAG = "ModelAgentManager" } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ModelSelectionCoordinator.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ModelSelectionCoordinator.kt new file mode 100644 index 00000000..559a1220 --- /dev/null +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ModelSelectionCoordinator.kt @@ -0,0 +1,18 @@ +package dev.blazelight.p4oc.ui.screens.chat + +import dev.blazelight.p4oc.data.remote.dto.ModelInput +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +/** + * Shares successful active-model writes with live chat model managers. + */ +class ModelSelectionCoordinator { + private val _activeModelChanges = MutableSharedFlow(extraBufferCapacity = 1) + val activeModelChanges: SharedFlow = _activeModelChanges.asSharedFlow() + + fun publishActiveModel(model: ModelInput) { + _activeModelChanges.tryEmit(model) + } +} diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/ModelControlsScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/ModelControlsScreen.kt index 48ad8ae6..a43af60c 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/ModelControlsScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/ModelControlsScreen.kt @@ -26,6 +26,7 @@ import dev.blazelight.p4oc.data.remote.dto.SetActiveModelRequest import dev.blazelight.p4oc.ui.components.TuiLoadingScreen import dev.blazelight.p4oc.ui.components.TuiSnackbar import dev.blazelight.p4oc.ui.components.TuiTopBar +import dev.blazelight.p4oc.ui.screens.chat.ModelSelectionCoordinator import dev.blazelight.p4oc.ui.theme.LocalOpenCodeTheme import dev.blazelight.p4oc.ui.theme.SemanticColors import dev.blazelight.p4oc.ui.theme.Sizing @@ -60,7 +61,8 @@ data class ModelControlsState( ) class ModelControlsViewModel constructor( - private val connectionManager: ConnectionManager + private val connectionManager: ConnectionManager, + private val modelSelectionCoordinator: ModelSelectionCoordinator = ModelSelectionCoordinator() ) : ViewModel() { private val _state = MutableStateFlow(ModelControlsState()) @@ -122,17 +124,36 @@ class ModelControlsViewModel constructor( fun selectModel(modelId: String) { viewModelScope.launch { - _state.update { it.copy(selectedModelId = modelId) } - val api = connectionManager.getApi() ?: return@launch - // Find the model to get its providerId - val model = _state.value.models.find { it.id == modelId } ?: return@launch + val previousModelId = _state.value.selectedModelId + val api = connectionManager.getApi() ?: run { + _state.update { it.copy(selectedModelId = previousModelId, error = "Not connected") } + return@launch + } + val model = _state.value.models.find { it.id == modelId } ?: run { + _state.update { it.copy(selectedModelId = previousModelId, error = "Model not available") } + return@launch + } val request = SetActiveModelRequest( model = ModelInput( providerID = model.providerId, modelID = model.id ) ) - safeApiCall { api.setActiveModel(request) } + when (val result = safeApiCall { api.setActiveModel(request) }) { + is ApiResult.Success -> { + if (result.data) { + _state.update { it.copy(selectedModelId = modelId, error = null) } + modelSelectionCoordinator.publishActiveModel(request.model) + } else { + _state.update { + it.copy(selectedModelId = previousModelId, error = "Failed to set active model") + } + } + } + is ApiResult.Error -> { + _state.update { it.copy(selectedModelId = previousModelId, error = result.message) } + } + } } } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/ProviderConfigViewModel.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/ProviderConfigViewModel.kt index ee455c94..f6242e5f 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/ProviderConfigViewModel.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/ProviderConfigViewModel.kt @@ -3,7 +3,9 @@ package dev.blazelight.p4oc.ui.screens.settings import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dev.blazelight.p4oc.core.network.ConnectionManager +import dev.blazelight.p4oc.data.remote.dto.ModelInput import dev.blazelight.p4oc.data.remote.dto.ProviderDto +import dev.blazelight.p4oc.ui.screens.chat.ModelSelectionCoordinator import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -21,7 +23,8 @@ data class ProviderConfigUiState( ) class ProviderConfigViewModel constructor( - private val connectionManager: ConnectionManager + private val connectionManager: ConnectionManager, + private val modelSelectionCoordinator: ModelSelectionCoordinator = ModelSelectionCoordinator() ) : ViewModel() { private val _uiState = MutableStateFlow(ProviderConfigUiState()) @@ -78,8 +81,10 @@ class ProviderConfigViewModel constructor( val currentConfig = api.getConfig() val newModel = "$providerId/$modelId" val updatedConfig = currentConfig.copy(model = newModel) - api.updateConfig(updatedConfig) - _uiState.update { it.copy(currentModel = newModel) } + val savedConfig = api.updateConfig(updatedConfig) + val savedModel = savedConfig.model ?: newModel + _uiState.update { it.copy(currentModel = savedModel, error = null) } + parseModelInput(savedModel)?.let(modelSelectionCoordinator::publishActiveModel) } catch (e: CancellationException) { throw e } catch (e: Exception) { @@ -87,4 +92,13 @@ class ProviderConfigViewModel constructor( } } } + + private fun parseModelInput(value: String): ModelInput? { + val separator = value.indexOf('/') + if (separator <= 0 || separator == value.lastIndex) return null + return ModelInput( + providerID = value.substring(0, separator), + modelID = value.substring(separator + 1) + ) + } } diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/ChatScrollRestorationTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/ChatScrollRestorationTest.kt new file mode 100644 index 00000000..44212d44 --- /dev/null +++ b/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/ChatScrollRestorationTest.kt @@ -0,0 +1,98 @@ +package dev.blazelight.p4oc.ui.screens.chat + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChatScrollRestorationTest { + + @Test + fun sameSessionRestoresAwayFromTailWithNewContentInsteadOfForcingTail() { + val state = ChatScrollRestorationState() + + state.onContentReady(hasRenderableTail = true) + state.onScrollSettled(isAtBottom = false) + state.onTailContentChanged(hasRenderableTail = true) + + assertFalse(state.shouldFollowTail) + assertTrue(state.didInitialTailScroll) + assertTrue(state.hasNewContentWhileAway) + } + + @Test + fun differentSessionsDoNotShareScrollRestorationState() { + val sessionA = ChatScrollRestorationState() + val sessionB = ChatScrollRestorationState() + + sessionA.onContentReady(hasRenderableTail = true) + sessionA.onScrollSettled(isAtBottom = false) + sessionA.onTailContentChanged(hasRenderableTail = true) + + assertTrue(sessionB.shouldFollowTail) + assertFalse(sessionB.didInitialTailScroll) + assertFalse(sessionB.hasNewContentWhileAway) + } + + @Test + fun searchNavigationDisablesFollowTailAndRestoresForSameSessionOnly() { + val state = ChatScrollRestorationState() + val otherSession = ChatScrollRestorationState() + + state.onContentReady(hasRenderableTail = true) + state.shouldFollowTail = false + state.onTailContentChanged(hasRenderableTail = true) + + assertFalse(state.shouldFollowTail) + assertTrue(state.hasNewContentWhileAway) + assertTrue(otherSession.shouldFollowTail) + assertFalse(otherSession.hasNewContentWhileAway) + } + + @Test + fun returningToOlderPositionDoesNotForceTailOnNextContentChange() { + val state = ChatScrollRestorationState() + + state.onContentReady(hasRenderableTail = true) + state.onScrollSettled(isAtBottom = false) + state.onTailContentChanged(hasRenderableTail = true) + + assertFalse(state.shouldFollowTail) + assertTrue(state.hasNewContentWhileAway) + } + + @Test + fun jumpToBottomResumesFollowTailAndClearsNewContentAffordance() { + val state = ChatScrollRestorationState() + + state.onContentReady(hasRenderableTail = true) + state.onScrollSettled(isAtBottom = false) + state.onTailContentChanged(hasRenderableTail = true) + state.onJumpToBottom() + + assertTrue(state.shouldFollowTail) + assertFalse(state.hasNewContentWhileAway) + } + + @Test + fun contentNotReadyDoesNotConsumeInitialTailRestoration() { + val state = ChatScrollRestorationState() + + assertEquals(InitialTailDecision.NoContent, state.onContentReady(hasRenderableTail = false)) + assertFalse(state.didInitialTailScroll) + + assertEquals(InitialTailDecision.ScrollToTail, state.onContentReady(hasRenderableTail = true)) + assertTrue(state.didInitialTailScroll) + } + + @Test + fun initialTailRestorationHappensOnceAndDoesNotOverrideRestoredAwayPosition() { + val state = ChatScrollRestorationState() + + assertEquals(InitialTailDecision.ScrollToTail, state.onContentReady(hasRenderableTail = true)) + state.onScrollSettled(isAtBottom = false) + + assertEquals(InitialTailDecision.KeepRestoredPosition, state.onContentReady(hasRenderableTail = true)) + assertFalse(state.shouldFollowTail) + } +} diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModelTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModelTest.kt index a0eebacb..7a5e0ce1 100644 --- a/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModelTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModelTest.kt @@ -16,7 +16,15 @@ import dev.blazelight.p4oc.core.network.ServerConfig import dev.blazelight.p4oc.data.files.FileRepository import dev.blazelight.p4oc.data.files.FileRepositoryFactory import dev.blazelight.p4oc.data.remote.dto.CommandDto +import dev.blazelight.p4oc.data.remote.dto.MessageInfoDto +import dev.blazelight.p4oc.data.remote.dto.MessageTimeDto +import dev.blazelight.p4oc.data.remote.dto.MessageWrapperDto +import dev.blazelight.p4oc.data.remote.dto.ModelRefDto +import dev.blazelight.p4oc.data.remote.dto.RevertSessionRequest import dev.blazelight.p4oc.data.remote.dto.SendMessageRequest +import dev.blazelight.p4oc.data.remote.dto.SessionDto +import dev.blazelight.p4oc.data.remote.dto.SessionRevertDto +import dev.blazelight.p4oc.data.remote.dto.TimeDto import dev.blazelight.p4oc.data.remote.mapper.MessageMapper import dev.blazelight.p4oc.data.server.ActiveServerApiProvider import dev.blazelight.p4oc.data.session.SessionRepositoryImpl @@ -59,6 +67,7 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain +import kotlinx.serialization.json.Json import kotlinx.serialization.json.buildJsonObject import okhttp3.ResponseBody.Companion.toResponseBody import org.junit.After @@ -105,7 +114,7 @@ class ChatViewModelTest { every { AppLog.e(any(), any(), any()) } returns Unit connectionManager = mockk() - messageMapper = mockk(relaxed = true) + messageMapper = MessageMapper(Json { ignoreUnknownKeys = true }) settingsDataStore = mockk() eventSource = mockk() events = MutableSharedFlow(extraBufferCapacity = 32) @@ -308,6 +317,62 @@ class ChatViewModelTest { assertEquals(SessionPresence.IDLE, vm.sessionConnectionState.value) } + @Test + fun sendMessage_undoSlashCommand_revertsToPreviousUserMessageBoundaryWithoutExecutingCommand() = + runTest { + coEvery { api.getSession("session-1", any()) } returns sessionDto(revertMessageId = "user-2") + coEvery { api.getMessages("session-1", any(), any()) } returns listOf( + userMessageDto("user-1", createdAt = 1), + assistantMessageDto("assistant-1", createdAt = 2), + userMessageDto("user-2", createdAt = 3), + assistantMessageDto("assistant-2", createdAt = 4), + ) + coEvery { api.revertSession(any(), any(), any()) } returns sessionDto(revertMessageId = "user-1") + coEvery { api.executeCommand(any(), any(), any()) } returns assistantMessageDto( + "command-response", + createdAt = 5 + ) + val vm = createViewModel() + + vm.updateInput("/undo") + vm.sendMessage() + advanceUntilIdle() + + coVerify(exactly = 0) { api.executeCommand(any(), any(), any()) } + coVerify(exactly = 1) { + api.revertSession("session-1", RevertSessionRequest(messageID = "user-1"), "/test") + } + } + + @Test + fun sendMessage_redoSlashCommandWithActiveRevert_revertsToNextUserMessageBoundaryWithoutExecutingCommand() = + runTest { + coEvery { api.getSession("session-1", any()) } returns sessionDto(revertMessageId = "user-1") + coEvery { api.getMessages("session-1", any(), any()) } returns listOf( + userMessageDto("user-1", createdAt = 1), + assistantMessageDto("assistant-1", createdAt = 2), + userMessageDto("user-2", createdAt = 3), + assistantMessageDto("assistant-2", createdAt = 4), + ) + coEvery { api.revertSession(any(), any(), any()) } returns sessionDto(revertMessageId = "user-2") + coEvery { api.unrevertSession(any(), any()) } returns sessionDto(revertMessageId = null) + coEvery { api.executeCommand(any(), any(), any()) } returns assistantMessageDto( + "command-response", + createdAt = 5 + ) + val vm = createViewModel() + + vm.updateInput("/redo") + vm.sendMessage() + advanceUntilIdle() + + coVerify(exactly = 0) { api.executeCommand(any(), any(), any()) } + coVerify(exactly = 1) { + api.revertSession("session-1", RevertSessionRequest(messageID = "user-2"), "/test") + } + coVerify(exactly = 0) { api.unrevertSession(any(), any()) } + } + @Test fun sendMessage_clearsInput_andMarksBusyUntilSseStatus() = runTest { val vm = createViewModel() @@ -516,6 +581,60 @@ class ChatViewModelTest { assertNull(vm.uiState.value.commandLoadError) } + @Test + fun executeCommand_undoPaletteSelection_revertsToPreviousUserMessageBoundaryWithoutExecutingCommandEndpoint() = + runTest { + coEvery { api.getSession("session-1", any()) } returns sessionDto(revertMessageId = "user-2") + coEvery { api.getMessages("session-1", any(), any()) } returns listOf( + userMessageDto("user-1", createdAt = 1), + assistantMessageDto("assistant-1", createdAt = 2), + userMessageDto("user-2", createdAt = 3), + assistantMessageDto("assistant-2", createdAt = 4), + ) + coEvery { api.revertSession(any(), any(), any()) } returns sessionDto(revertMessageId = "user-1") + coEvery { api.executeCommand(any(), any(), any()) } returns assistantMessageDto( + "command-response", + createdAt = 5 + ) + val vm = createViewModel() + + vm.executeCommand("undo", "") + advanceUntilIdle() + + coVerify(exactly = 0) { api.executeCommand(any(), any(), any()) } + coVerify(exactly = 1) { + api.revertSession("session-1", RevertSessionRequest(messageID = "user-1"), "/test") + } + } + + @Test + fun executeCommand_redoPaletteSelectionWithActiveRevert_usesRevertBoundaryNotCommandEndpoint() = + runTest { + coEvery { api.getSession("session-1", any()) } returns sessionDto(revertMessageId = "user-1") + coEvery { api.getMessages("session-1", any(), any()) } returns listOf( + userMessageDto("user-1", createdAt = 1), + assistantMessageDto("assistant-1", createdAt = 2), + userMessageDto("user-2", createdAt = 3), + assistantMessageDto("assistant-2", createdAt = 4), + ) + coEvery { api.revertSession(any(), any(), any()) } returns sessionDto(revertMessageId = "user-2") + coEvery { api.unrevertSession(any(), any()) } returns sessionDto(revertMessageId = null) + coEvery { api.executeCommand(any(), any(), any()) } returns assistantMessageDto( + "command-response", + createdAt = 5 + ) + val vm = createViewModel() + + vm.executeCommand("redo", "") + advanceUntilIdle() + + coVerify(exactly = 0) { api.executeCommand(any(), any(), any()) } + coVerify(exactly = 1) { + api.revertSession("session-1", RevertSessionRequest(messageID = "user-2"), "/test") + } + coVerify(exactly = 0) { api.unrevertSession(any(), any()) } + } + private fun TestScope.createViewModel(): ChatViewModel { sessionRepository = SessionRepositoryImpl( workspaceClient, @@ -563,6 +682,49 @@ class ChatViewModelTest { private fun ChatViewModel.currentMessages(): List = messages.value + private fun sessionDto(revertMessageId: String? = null): SessionDto { + return SessionDto( + id = "session-1", + projectID = "project-1", + directory = "/test", + title = "Test Session", + version = "1.0", + time = TimeDto(created = 1, updated = 2), + revert = revertMessageId?.let { SessionRevertDto(messageID = it) }, + ) + } + + private fun userMessageDto(id: String, createdAt: Long): MessageWrapperDto { + return MessageWrapperDto( + info = MessageInfoDto( + id = id, + sessionID = "session-1", + time = MessageTimeDto(created = createdAt), + role = "user", + agent = "build", + model = ModelRefDto(providerID = "provider", modelID = "model"), + ), + parts = emptyList(), + ) + } + + private fun assistantMessageDto(id: String, createdAt: Long): MessageWrapperDto { + return MessageWrapperDto( + info = MessageInfoDto( + id = id, + sessionID = "session-1", + time = MessageTimeDto(created = createdAt), + role = "assistant", + parentID = "", + providerID = "provider", + modelID = "model", + agent = "assistant", + mode = "chat", + ), + parts = emptyList(), + ) + } + private fun assistantMessage(id: String, sessionId: String, createdAt: Long): Message.Assistant { return Message.Assistant( id = id, diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/ModelAgentManagerTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/ModelAgentManagerTest.kt index 23a402b9..7a65187a 100644 --- a/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/ModelAgentManagerTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/ModelAgentManagerTest.kt @@ -19,6 +19,7 @@ import io.mockk.unmockkObject import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Assert.* @@ -87,7 +88,7 @@ class ModelAgentManagerTest { } @Test - fun `loadAgents selects build agent by default`() = runTest { + fun `loadAgents selects first primary agent from server order`() = runTest { val agents = listOf( makeAgent("code", mode = "primary"), makeAgent("build", mode = "primary"), @@ -99,7 +100,7 @@ class ModelAgentManagerTest { manager.loadAgents() advanceUntilIdle() - assertEquals("build", manager.selectedAgent.value) + assertEquals("code", manager.selectedAgent.value) } @Test @@ -254,6 +255,154 @@ class ModelAgentManagerTest { assertEquals("claude-3", selected.modelID) } + @Test + fun `loadModels prefers server default over app recent model`() = runTest { + val recentModel = ModelInput(providerID = "anthropic", modelID = "claude-3") + every { settingsDataStore.recentModels } returns flowOf(listOf(recentModel)) + + val providersResponse = ProvidersResponseDto( + all = listOf( + ProviderDto( + id = "anthropic", + name = "Anthropic", + source = "env", + models = mapOf( + "claude-3" to makeModel("claude-3", "anthropic") + ) + ), + ProviderDto( + id = "openai", + name = "OpenAI", + source = "env", + models = mapOf( + "gpt-4" to makeModel("gpt-4", "openai") + ) + ) + ), + default = mapOf("openai" to "gpt-4"), + connected = listOf("anthropic", "openai") + ) + coEvery { api.getProviders() } returns providersResponse + + val manager = ModelAgentManager(connectionManager, settingsDataStore, this) + advanceUntilIdle() + + manager.loadModels() + advanceUntilIdle() + + assertEquals(ModelInput(providerID = "openai", modelID = "gpt-4"), manager.selectedModel.value) + } + + @Test + fun `loadModels keeps explicit user selected model when still available`() = runTest { + val selectedModel = ModelInput(providerID = "anthropic", modelID = "claude-3") + val providersResponse = ProvidersResponseDto( + all = listOf( + ProviderDto( + id = "anthropic", + name = "Anthropic", + source = "env", + models = mapOf( + "claude-3" to makeModel("claude-3", "anthropic") + ) + ), + ProviderDto( + id = "openai", + name = "OpenAI", + source = "env", + models = mapOf( + "gpt-4" to makeModel("gpt-4", "openai") + ) + ) + ), + default = mapOf("openai" to "gpt-4"), + connected = listOf("anthropic", "openai") + ) + coEvery { api.getProviders() } returns providersResponse + + val manager = ModelAgentManager(connectionManager, settingsDataStore, this) + manager.selectModel(selectedModel) + advanceUntilIdle() + + manager.loadModels() + advanceUntilIdle() + + assertEquals(selectedModel, manager.selectedModel.value) + } + + @Test + fun `loadModels reconciles unavailable explicit model to server default`() = runTest { + val staleModel = ModelInput(providerID = "anthropic", modelID = "claude-3") + val providersResponse = ProvidersResponseDto( + all = listOf( + ProviderDto( + id = "openai", + name = "OpenAI", + source = "env", + models = mapOf( + "gpt-4" to makeModel("gpt-4", "openai") + ) + ) + ), + default = mapOf("openai" to "gpt-4"), + connected = listOf("openai") + ) + coEvery { api.getProviders() } returns providersResponse + + val manager = ModelAgentManager(connectionManager, settingsDataStore, this) + manager.selectModel(staleModel) + advanceUntilIdle() + + manager.loadModels() + advanceUntilIdle() + + assertEquals(ModelInput(providerID = "openai", modelID = "gpt-4"), manager.selectedModel.value) + } + + @Test + fun `loadModels does not infer reasoning effort from first available variant without explicit default`() = runTest { + every { settingsDataStore.recentModels } returns flowOf(emptyList()) + + val providersResponse = ProvidersResponseDto( + all = listOf( + ProviderDto( + id = "anthropic", + name = "Anthropic", + source = "env", + models = mapOf( + "claude-3" to ModelDto( + id = "claude-3", + providerId = "anthropic", + name = "Claude 3", + variants = kotlinx.serialization.json.JsonObject( + mapOf( + "low" to kotlinx.serialization.json.JsonObject(emptyMap()), + "high" to kotlinx.serialization.json.JsonObject(emptyMap()) + ) + ) + ) + ) + ) + ), + default = mapOf("anthropic" to "claude-3"), + connected = listOf("anthropic") + ) + coEvery { api.getProviders() } returns providersResponse + + val manager = ModelAgentManager(connectionManager, settingsDataStore, this) + advanceUntilIdle() + + manager.loadModels() + advanceUntilIdle() + + assertEquals(ModelInput(providerID = "anthropic", modelID = "claude-3"), manager.selectedModel.value) + assertNull( + "A provider/model default is not a reasoning-effort default; absent explicit upstream/user effort, " + + "do not silently choose the first representable variant.", + manager.currentReasoningEffort() + ) + } + @Test fun `loadModels selects default model when no recent`() = runTest { every { settingsDataStore.recentModels } returns flowOf(emptyList()) @@ -286,6 +435,57 @@ class ModelAgentManagerTest { assertEquals("gpt-4", selected.modelID) } + @Test + fun `active model change updates selection when no agent or explicit model override`() = runTest { + val coordinator = ModelSelectionCoordinator() + val manager = ModelAgentManager(connectionManager, settingsDataStore, backgroundScope, null, coordinator) + advanceUntilIdle() + runCurrent() + + val publishedModel = ModelInput(providerID = "anthropic", modelID = "claude-3") + coordinator.publishActiveModel(publishedModel) + runCurrent() + + assertEquals(publishedModel, manager.selectedModel.value) + } + + @Test + fun `active model change does not replace explicit user selection`() = runTest { + val coordinator = ModelSelectionCoordinator() + val manager = ModelAgentManager(connectionManager, settingsDataStore, backgroundScope, null, coordinator) + val explicitModel = ModelInput(providerID = "openai", modelID = "gpt-4") + manager.selectModel(explicitModel) + advanceUntilIdle() + runCurrent() + + coordinator.publishActiveModel(ModelInput(providerID = "anthropic", modelID = "claude-3")) + runCurrent() + + assertEquals(explicitModel, manager.selectedModel.value) + } + + @Test + fun `active model change does not replace agent model selection`() = runTest { + val coordinator = ModelSelectionCoordinator() + val agents = listOf( + makeAgent( + "code", + mode = "primary", + model = ModelRefDto(providerID = "openai", modelID = "gpt-4") + ) + ) + coEvery { api.getAgents() } returns agents + val manager = ModelAgentManager(connectionManager, settingsDataStore, backgroundScope, null, coordinator) + manager.loadAgents() + advanceUntilIdle() + runCurrent() + + coordinator.publishActiveModel(ModelInput(providerID = "anthropic", modelID = "claude-3")) + runCurrent() + + assertEquals(ModelInput(providerID = "openai", modelID = "gpt-4"), manager.selectedModel.value) + } + // ── selectModel ───────────────────────────────────────────────────────── @Test diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/screens/settings/ModelControlsViewModelTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/screens/settings/ModelControlsViewModelTest.kt new file mode 100644 index 00000000..139b3b8e --- /dev/null +++ b/app/src/test/java/dev/blazelight/p4oc/ui/screens/settings/ModelControlsViewModelTest.kt @@ -0,0 +1,189 @@ +package dev.blazelight.p4oc.ui.screens.settings + +import dev.blazelight.p4oc.core.network.ConnectionManager +import dev.blazelight.p4oc.core.network.OpenCodeApi +import dev.blazelight.p4oc.data.remote.dto.ModelDto +import dev.blazelight.p4oc.data.remote.dto.ModelInput +import dev.blazelight.p4oc.data.remote.dto.ProviderDto +import dev.blazelight.p4oc.data.remote.dto.ProvidersResponseDto +import dev.blazelight.p4oc.data.remote.dto.SetActiveModelRequest +import dev.blazelight.p4oc.ui.screens.chat.ModelSelectionCoordinator +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ModelControlsViewModelTest { + private val dispatcher = StandardTestDispatcher() + private val connectionManager: ConnectionManager = mockk() + private val api: OpenCodeApi = mockk() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + every { connectionManager.getApi() } returns api + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun selectModel_successUpdatesSelectedStateAndClearsPreviousError() = runTest(dispatcher) { + coEvery { api.getProviders() } returns providersResponse() + coEvery { api.setActiveModel(activeModelRequest("anthropic", "claude-3")) } returns false + val viewModel = ModelControlsViewModel(connectionManager) + advanceUntilIdle() + viewModel.selectModel("claude-3") + advanceUntilIdle() + assertEquals("Failed to set active model", viewModel.state.value.error) + + coEvery { api.setActiveModel(activeModelRequest("openai", "gpt-4")) } returns true + viewModel.selectModel("gpt-4") + advanceUntilIdle() + + assertEquals("gpt-4", viewModel.state.value.selectedModelId) + assertNull(viewModel.state.value.error) + } + + @Test + fun selectModel_publishesCoordinatorChangeOnlyAfterSuccessfulApiUpdate() = runTest(dispatcher) { + val coordinator = ModelSelectionCoordinator() + val publishedModels = mutableListOf() + val collectJob = backgroundScope.launch { + coordinator.activeModelChanges.collect(publishedModels::add) + } + runCurrent() + coEvery { api.getProviders() } returns providersResponse() + coEvery { api.setActiveModel(activeModelRequest("anthropic", "claude-3")) } returns false + coEvery { api.setActiveModel(activeModelRequest("openai", "gpt-4")) } returns true + val viewModel = ModelControlsViewModel(connectionManager, coordinator) + advanceUntilIdle() + + viewModel.selectModel("claude-3") + runCurrent() + assertEquals(emptyList(), publishedModels) + + viewModel.selectModel("gpt-4") + runCurrent() + + assertEquals(listOf(ModelInput(providerID = "openai", modelID = "gpt-4")), publishedModels) + collectJob.cancel() + } + + @Test + fun selectModel_apiErrorPreservesPreviousSelectionAndShowsMessage() = runTest(dispatcher) { + coEvery { api.getProviders() } returns providersResponse() + coEvery { api.setActiveModel(activeModelRequest("openai", "gpt-4")) } returns true + coEvery { api.setActiveModel(activeModelRequest("anthropic", "claude-3")) } throws + IllegalStateException("server rejected model") + val viewModel = ModelControlsViewModel(connectionManager) + advanceUntilIdle() + viewModel.selectModel("gpt-4") + advanceUntilIdle() + + viewModel.selectModel("claude-3") + advanceUntilIdle() + + assertEquals("gpt-4", viewModel.state.value.selectedModelId) + assertEquals("server rejected model", viewModel.state.value.error) + } + + @Test + fun selectModel_falseSuccessPreservesPreviousSelectionAndShowsMessage() = runTest(dispatcher) { + coEvery { api.getProviders() } returns providersResponse() + coEvery { api.setActiveModel(activeModelRequest("openai", "gpt-4")) } returns true + coEvery { api.setActiveModel(activeModelRequest("anthropic", "claude-3")) } returns false + val viewModel = ModelControlsViewModel(connectionManager) + advanceUntilIdle() + viewModel.selectModel("gpt-4") + advanceUntilIdle() + + viewModel.selectModel("claude-3") + advanceUntilIdle() + + assertEquals("gpt-4", viewModel.state.value.selectedModelId) + assertEquals("Failed to set active model", viewModel.state.value.error) + } + + @Test + fun selectModel_missingApiDoesNotLeaveOptimisticSelection() = runTest(dispatcher) { + coEvery { api.getProviders() } returns providersResponse() + coEvery { api.setActiveModel(activeModelRequest("openai", "gpt-4")) } returns true + val viewModel = ModelControlsViewModel(connectionManager) + advanceUntilIdle() + viewModel.selectModel("gpt-4") + advanceUntilIdle() + every { connectionManager.getApi() } returns null + + viewModel.selectModel("claude-3") + advanceUntilIdle() + + assertEquals("gpt-4", viewModel.state.value.selectedModelId) + assertEquals("Not connected", viewModel.state.value.error) + } + + @Test + fun selectModel_missingModelDoesNotLeaveOptimisticSelection() = runTest(dispatcher) { + coEvery { api.getProviders() } returns providersResponse() + coEvery { api.setActiveModel(activeModelRequest("openai", "gpt-4")) } returns true + val viewModel = ModelControlsViewModel(connectionManager) + advanceUntilIdle() + viewModel.selectModel("gpt-4") + advanceUntilIdle() + + viewModel.selectModel("missing-model") + advanceUntilIdle() + + assertEquals("gpt-4", viewModel.state.value.selectedModelId) + assertEquals("Model not available", viewModel.state.value.error) + coVerify(exactly = 0) { + api.setActiveModel(activeModelRequest("anthropic", "missing-model")) + } + } + + private fun providersResponse() = ProvidersResponseDto( + all = listOf( + ProviderDto( + id = "openai", + name = "OpenAI", + source = "env", + models = mapOf("gpt-4" to model("gpt-4", "openai")) + ), + ProviderDto( + id = "anthropic", + name = "Anthropic", + source = "env", + models = mapOf("claude-3" to model("claude-3", "anthropic")) + ) + ), + default = mapOf("openai" to "gpt-4"), + connected = listOf("openai", "anthropic") + ) + + private fun model(id: String, providerId: String) = ModelDto( + id = id, + providerId = providerId, + name = "Model $id" + ) + + private fun activeModelRequest(providerId: String, modelId: String) = SetActiveModelRequest( + model = ModelInput(providerID = providerId, modelID = modelId) + ) +} diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/screens/settings/ProviderConfigViewModelTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/screens/settings/ProviderConfigViewModelTest.kt new file mode 100644 index 00000000..5282cc2e --- /dev/null +++ b/app/src/test/java/dev/blazelight/p4oc/ui/screens/settings/ProviderConfigViewModelTest.kt @@ -0,0 +1,124 @@ +package dev.blazelight.p4oc.ui.screens.settings + +import dev.blazelight.p4oc.core.network.ConnectionManager +import dev.blazelight.p4oc.core.network.OpenCodeApi +import dev.blazelight.p4oc.data.remote.dto.ConfigDto +import dev.blazelight.p4oc.data.remote.dto.ModelInput +import dev.blazelight.p4oc.data.remote.dto.ProviderDto +import dev.blazelight.p4oc.data.remote.dto.ProvidersResponseDto +import dev.blazelight.p4oc.ui.screens.chat.ModelSelectionCoordinator +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ProviderConfigViewModelTest { + private val dispatcher = StandardTestDispatcher() + private val connectionManager: ConnectionManager = mockk() + private val api: OpenCodeApi = mockk() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + every { connectionManager.getApi() } returns api + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun setModel_updatesCurrentModelOnlyAfterUpdateConfigSucceeds() = runTest(dispatcher) { + coEvery { api.getProviders() } returns providersResponse() + coEvery { api.getConfig() } returns ConfigDto(model = "openai/gpt-4") + coEvery { api.updateConfig(ConfigDto(model = "anthropic/claude-3")) } returns + ConfigDto(model = "anthropic/claude-3") + val viewModel = ProviderConfigViewModel(connectionManager) + advanceUntilIdle() + + viewModel.setModel("anthropic", "claude-3") + assertEquals("openai/gpt-4", viewModel.uiState.value.currentModel) + advanceUntilIdle() + + assertEquals("anthropic/claude-3", viewModel.uiState.value.currentModel) + assertNull(viewModel.uiState.value.error) + coVerify { api.updateConfig(ConfigDto(model = "anthropic/claude-3")) } + } + + @Test + fun setModel_publishesCoordinatorChangeOnlyAfterUpdateConfigSucceeds() = runTest(dispatcher) { + val coordinator = ModelSelectionCoordinator() + val publishedModels = mutableListOf() + val collectJob = backgroundScope.launch { + coordinator.activeModelChanges.collect(publishedModels::add) + } + runCurrent() + coEvery { api.getProviders() } returns providersResponse() + coEvery { api.getConfig() } returns ConfigDto(model = "openai/gpt-4") + coEvery { api.updateConfig(ConfigDto(model = "anthropic/claude-3")) } throws + IllegalStateException("config write failed") + val viewModel = ProviderConfigViewModel(connectionManager, coordinator) + advanceUntilIdle() + + viewModel.setModel("anthropic", "claude-3") + runCurrent() + assertEquals(emptyList(), publishedModels) + + coEvery { api.updateConfig(ConfigDto(model = "anthropic/claude-3")) } returns + ConfigDto(model = "anthropic/claude-3") + viewModel.setModel("anthropic", "claude-3") + runCurrent() + + assertEquals(listOf(ModelInput(providerID = "anthropic", modelID = "claude-3")), publishedModels) + collectJob.cancel() + } + + @Test + fun setModel_updateConfigExceptionPreservesPreviousModelAndShowsMessage() = runTest(dispatcher) { + coEvery { api.getProviders() } returns providersResponse() + coEvery { api.getConfig() } returns ConfigDto(model = "openai/gpt-4") + coEvery { api.updateConfig(ConfigDto(model = "anthropic/claude-3")) } throws + IllegalStateException("config write failed") + val viewModel = ProviderConfigViewModel(connectionManager) + advanceUntilIdle() + + viewModel.setModel("anthropic", "claude-3") + advanceUntilIdle() + + assertEquals("openai/gpt-4", viewModel.uiState.value.currentModel) + assertEquals("config write failed", viewModel.uiState.value.error) + } + + private fun providersResponse() = ProvidersResponseDto( + all = listOf( + ProviderDto( + id = "openai", + name = "OpenAI", + source = "env" + ), + ProviderDto( + id = "anthropic", + name = "Anthropic", + source = "env" + ) + ), + default = mapOf("openai" to "gpt-4"), + connected = listOf("openai", "anthropic") + ) +} From 35ddaab85d75540d51d063510a35d81f8f0900ab Mon Sep 17 00:00:00 2001 From: Jasmin Le Roux Date: Mon, 6 Jul 2026 13:30:28 +0200 Subject: [PATCH 03/22] Move permission titles to UI boundary --- .../notification/NotificationEventObserver.kt | 4 +- .../core/notification/NotificationHelper.kt | 7 +- .../p4oc/domain/model/Permission.kt | 17 ----- .../components/chat/InlinePermissionPrompt.kt | 3 +- .../permission/PermissionDisplayFormatter.kt | 67 +++++++++++++++++++ app/src/main/res/values/strings.xml | 10 +++ .../data/remote/mapper/EventMapperTest.kt | 6 +- .../p4oc/domain/model/ToolStateExtTest.kt | 34 ++++++++++ .../PermissionDisplayFormatterTest.kt | 61 +++++++++++++++++ 9 files changed, 186 insertions(+), 23 deletions(-) create mode 100644 app/src/main/java/dev/blazelight/p4oc/ui/permission/PermissionDisplayFormatter.kt create mode 100644 app/src/test/java/dev/blazelight/p4oc/ui/permission/PermissionDisplayFormatterTest.kt diff --git a/app/src/main/java/dev/blazelight/p4oc/core/notification/NotificationEventObserver.kt b/app/src/main/java/dev/blazelight/p4oc/core/notification/NotificationEventObserver.kt index d7659edd..04fd69db 100644 --- a/app/src/main/java/dev/blazelight/p4oc/core/notification/NotificationEventObserver.kt +++ b/app/src/main/java/dev/blazelight/p4oc/core/notification/NotificationEventObserver.kt @@ -94,10 +94,10 @@ class NotificationEventObserver constructor( when (event) { is OpenCodeEvent.PermissionRequested -> { if (!cachedSettings.permissionRequests) return - AppLog.d(TAG, "Permission requested in background: ${event.permission.title}") + AppLog.d(TAG, "Permission requested in background: ${event.permission.type}") notificationHelper.showPermissionNotification( sessionId = event.permission.sessionID, - title = event.permission.title + permission = event.permission ) } is OpenCodeEvent.QuestionAsked -> { diff --git a/app/src/main/java/dev/blazelight/p4oc/core/notification/NotificationHelper.kt b/app/src/main/java/dev/blazelight/p4oc/core/notification/NotificationHelper.kt index dbea246b..aa32696b 100644 --- a/app/src/main/java/dev/blazelight/p4oc/core/notification/NotificationHelper.kt +++ b/app/src/main/java/dev/blazelight/p4oc/core/notification/NotificationHelper.kt @@ -11,6 +11,8 @@ import androidx.core.app.NotificationManagerCompat import dev.blazelight.p4oc.MainActivity import dev.blazelight.p4oc.R import dev.blazelight.p4oc.core.log.AppLog +import dev.blazelight.p4oc.domain.model.Permission +import dev.blazelight.p4oc.ui.permission.PermissionDisplayFormatter private const val TAG = "NotificationHelper" @@ -69,7 +71,7 @@ class NotificationHelper constructor( } } - fun showPermissionNotification(sessionId: String, title: String) { + fun showPermissionNotification(sessionId: String, permission: Permission) { val notificationId = permissionNotificationId(sessionId) val intent = Intent(context, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP @@ -84,9 +86,10 @@ class NotificationHelper constructor( PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) + val title = PermissionDisplayFormatter.title(context, permission) val notification = NotificationCompat.Builder(context, CHANNEL_ID) .setSmallIcon(R.drawable.ic_launcher_foreground) - .setContentTitle("Permission Required") + .setContentTitle(context.getString(R.string.notification_permission_required)) .setContentText(title) .setPriority(NotificationCompat.PRIORITY_HIGH) .setAutoCancel(true) diff --git a/app/src/main/java/dev/blazelight/p4oc/domain/model/Permission.kt b/app/src/main/java/dev/blazelight/p4oc/domain/model/Permission.kt index b6916e31..d22b52b6 100644 --- a/app/src/main/java/dev/blazelight/p4oc/domain/model/Permission.kt +++ b/app/src/main/java/dev/blazelight/p4oc/domain/model/Permission.kt @@ -16,23 +16,6 @@ data class Permission( ) { val kind: PermissionKind get() = PermissionKind.fromType(type) - - val title: String - get() { - val action = when (kind) { - PermissionKind.Bash -> "Execute command" - PermissionKind.Edit -> "Write to file" - PermissionKind.Patch -> "Edit file" - PermissionKind.WebFetch -> "Fetch URL" - PermissionKind.Task -> "Run sub-agent" - PermissionKind.Skill -> "Use skill" - PermissionKind.ExternalDirectory -> "Access external directory" - PermissionKind.DoomLoop -> "Continue execution" - PermissionKind.Unknown -> type.replaceFirstChar { it.uppercase() } - } - val pattern = patterns.firstOrNull().orEmpty() - return if (pattern.isNotEmpty()) "$action: $pattern" else action - } } enum class PermissionKind { diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/InlinePermissionPrompt.kt b/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/InlinePermissionPrompt.kt index 53924e23..0296ac99 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/InlinePermissionPrompt.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/InlinePermissionPrompt.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import dev.blazelight.p4oc.R import dev.blazelight.p4oc.domain.model.Permission +import dev.blazelight.p4oc.ui.permission.permissionTitle import dev.blazelight.p4oc.ui.theme.LocalOpenCodeTheme import dev.blazelight.p4oc.ui.theme.Sizing import dev.blazelight.p4oc.ui.theme.Spacing @@ -48,7 +49,7 @@ fun InlinePermissionPrompt( color = theme.warning ) Text( - text = permission.title, + text = permissionTitle(permission), style = MaterialTheme.typography.labelMedium.copy( fontFamily = FontFamily.Monospace, fontSize = TuiCodeFontSize.lg diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/permission/PermissionDisplayFormatter.kt b/app/src/main/java/dev/blazelight/p4oc/ui/permission/PermissionDisplayFormatter.kt new file mode 100644 index 00000000..586d2490 --- /dev/null +++ b/app/src/main/java/dev/blazelight/p4oc/ui/permission/PermissionDisplayFormatter.kt @@ -0,0 +1,67 @@ +package dev.blazelight.p4oc.ui.permission + +import android.content.Context +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import dev.blazelight.p4oc.R +import dev.blazelight.p4oc.domain.model.Permission +import dev.blazelight.p4oc.domain.model.PermissionKind + +object PermissionDisplayFormatter { + fun title(context: Context, permission: Permission): String { + val action = actionText(context, permission) + return title(context, action, permission.patterns) + } + + fun title(context: Context, actionText: String, patterns: List): String { + val pattern = patterns.firstOrNull().orEmpty() + return if (pattern.isNotBlank()) { + context.getString(R.string.permission_title_with_pattern, actionText, pattern) + } else { + actionText + } + } + + fun actionText(context: Context, permission: Permission): String { + val actionRes = actionStringRes(permission.kind) + return if (actionRes != null) { + context.getString(actionRes) + } else { + context.getString(R.string.permission_action_unknown, unknownAction(permission.type)) + } + } + + @StringRes + fun actionStringRes(kind: PermissionKind): Int? = when (kind) { + PermissionKind.Bash -> R.string.permission_action_bash + PermissionKind.Edit -> R.string.permission_action_edit + PermissionKind.Patch -> R.string.permission_action_patch + PermissionKind.WebFetch -> R.string.permission_action_webfetch + PermissionKind.Task -> R.string.permission_action_task + PermissionKind.Skill -> R.string.permission_action_skill + PermissionKind.ExternalDirectory -> R.string.permission_action_external_directory + PermissionKind.DoomLoop -> R.string.permission_action_doom_loop + PermissionKind.Unknown -> null + } + + fun unknownAction(type: String): String = type.replaceFirstChar { char -> + if (char.isLowerCase()) char.titlecase() else char.toString() + } +} + +@Composable +fun permissionTitle(permission: Permission): String { + val actionRes = PermissionDisplayFormatter.actionStringRes(permission.kind) + val actionText = if (actionRes != null) { + stringResource(actionRes) + } else { + stringResource(R.string.permission_action_unknown, PermissionDisplayFormatter.unknownAction(permission.type)) + } + val pattern = permission.patterns.firstOrNull().orEmpty() + return if (pattern.isNotBlank()) { + stringResource(R.string.permission_title_with_pattern, actionText, pattern) + } else { + actionText + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 990cd871..2734094b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -407,6 +407,16 @@ Permission Required + Execute command + Write to file + Edit file + Fetch URL + Run sub-agent + Use skill + Access external directory + Continue execution + %1$s + %1$s: %2$s View full output diff --git a/app/src/test/java/dev/blazelight/p4oc/data/remote/mapper/EventMapperTest.kt b/app/src/test/java/dev/blazelight/p4oc/data/remote/mapper/EventMapperTest.kt index 7c75d88c..bf2618ac 100644 --- a/app/src/test/java/dev/blazelight/p4oc/data/remote/mapper/EventMapperTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/data/remote/mapper/EventMapperTest.kt @@ -5,6 +5,7 @@ import dev.blazelight.p4oc.data.remote.dto.EventDataDto import dev.blazelight.p4oc.domain.model.Message import dev.blazelight.p4oc.domain.model.OpenCodeEvent import dev.blazelight.p4oc.domain.model.Part +import dev.blazelight.p4oc.domain.model.PermissionKind import dev.blazelight.p4oc.domain.model.SessionStatus import io.mockk.every import io.mockk.mockkObject @@ -150,7 +151,8 @@ class EventMapperTest { assertEquals("sess-1", perm.sessionID) assertEquals("msg-42", perm.messageID) assertEquals("call-99", perm.callID) - assertEquals("Execute command: rm -rf /tmp/test", perm.title) + assertEquals(PermissionKind.Bash, perm.kind) + assertEquals(buildJsonObject { put("key", "value") }, perm.metadata) assertEquals(listOf("once"), perm.always) } @@ -188,6 +190,8 @@ class EventMapperTest { assertEquals("sess-1", perm.sessionID) assertEquals("msg-42", perm.messageID) assertEquals("call-99", perm.callID) + assertEquals(PermissionKind.Bash, perm.kind) + assertEquals(buildJsonObject { put("key", "value") }, perm.metadata) assertEquals(listOf("npm test"), perm.always) } diff --git a/app/src/test/java/dev/blazelight/p4oc/domain/model/ToolStateExtTest.kt b/app/src/test/java/dev/blazelight/p4oc/domain/model/ToolStateExtTest.kt index caed6105..ae432c54 100644 --- a/app/src/test/java/dev/blazelight/p4oc/domain/model/ToolStateExtTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/domain/model/ToolStateExtTest.kt @@ -97,4 +97,38 @@ class ToolStateExtTest { assertTrue(questionTool.isQuestionTool()) assertFalse(otherTool.isQuestionTool()) } + + @Test + fun `permission domain does not expose localized display title`() { + val permission = Permission( + id = "perm-1", + type = "bash", + patterns = listOf("rm -rf /tmp/test"), + sessionID = "sess-1", + messageID = "msg-1", + callID = "call-1", + metadata = buildJsonObject {}, + always = emptyList() + ) + + assertEquals("bash", permission.type) + assertEquals(listOf("rm -rf /tmp/test"), permission.patterns) + assertEquals(PermissionKind.Bash, permission.kind) + assertEquals(buildJsonObject {}, permission.metadata) + assertEquals(emptyList(), permission.always) + + val localizedTitleSurfaces = permission.javaClass.methods + .filter { method -> + method.parameterCount == 0 && + method.returnType == String::class.java && + method.name in setOf("getTitle", "title", "getDisplayTitle", "displayTitle") + } + + assertTrue( + "Permission is a domain transport object. Localized/display titles must be derived at the " + + "UI/resource boundary, not exposed as String title/displayTitle API from the domain model. Found: " + + localizedTitleSurfaces.joinToString { it.name }, + localizedTitleSurfaces.isEmpty() + ) + } } diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/permission/PermissionDisplayFormatterTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/permission/PermissionDisplayFormatterTest.kt new file mode 100644 index 00000000..8d37172b --- /dev/null +++ b/app/src/test/java/dev/blazelight/p4oc/ui/permission/PermissionDisplayFormatterTest.kt @@ -0,0 +1,61 @@ +package dev.blazelight.p4oc.ui.permission + +import android.content.Context +import dev.blazelight.p4oc.R +import dev.blazelight.p4oc.domain.model.PermissionKind +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class PermissionDisplayFormatterTest { + + private val context = mockk { + every { + getString(R.string.permission_title_with_pattern, any(), any()) + } answers { + val formatArgs = arg>(1) + "${formatArgs[0]}: ${formatArgs[1]}" + } + } + + @Test + fun `known permission kinds resolve to localized action resources`() { + val cases = listOf( + PermissionKind.Bash to R.string.permission_action_bash, + PermissionKind.Edit to R.string.permission_action_edit, + PermissionKind.Patch to R.string.permission_action_patch, + PermissionKind.WebFetch to R.string.permission_action_webfetch, + PermissionKind.Task to R.string.permission_action_task, + PermissionKind.Skill to R.string.permission_action_skill, + PermissionKind.ExternalDirectory to R.string.permission_action_external_directory, + PermissionKind.DoomLoop to R.string.permission_action_doom_loop, + ) + + cases.forEach { (kind, expectedResource) -> + assertEquals("resource for $kind", expectedResource, PermissionDisplayFormatter.actionStringRes(kind)) + } + assertNull(PermissionDisplayFormatter.actionStringRes(PermissionKind.Unknown)) + } + + @Test + fun `unknown permission action preserves raw type except first-character capitalization`() { + assertEquals("Custom_tool", PermissionDisplayFormatter.unknownAction("custom_tool")) + assertEquals("MCPTool", PermissionDisplayFormatter.unknownAction("MCPTool")) + } + + @Test + fun `title appends first non-empty pattern to already localized action`() { + assertEquals( + "Execute command: rm -rf /tmp/test", + PermissionDisplayFormatter.title(context, "Execute command", listOf("rm -rf /tmp/test", "ignored")) + ) + } + + @Test + fun `title returns localized action when first pattern is absent or blank`() { + assertEquals("Execute command", PermissionDisplayFormatter.title(context, "Execute command", emptyList())) + assertEquals("Execute command", PermissionDisplayFormatter.title(context, "Execute command", listOf(""))) + } +} From 6dbe7b1671f46d88a181612c17d1fb3ba1dae93d Mon Sep 17 00:00:00 2001 From: Jasmin Le Roux Date: Mon, 6 Jul 2026 21:25:46 +0200 Subject: [PATCH 04/22] Align chat follow-up queue with upstream --- .tickets/oa-77dh.md | 41 ++++++ .tickets/oa-cplr.md | 34 +++++ .tickets/oa-uahy.md | 33 +++++ .tickets/oa-x112.md | 33 +++++ app/detekt-baseline.xml | 26 ++-- .../p4oc/ui/components/chat/ChatInputBar.kt | 30 +--- .../p4oc/ui/components/chat/ChatMessage.kt | 27 +++- .../ui/components/chat/QueuedMessagesStrip.kt | 137 ------------------ .../p4oc/ui/preview/ComponentPreviews.kt | 24 --- .../p4oc/ui/screens/chat/ChatScreen.kt | 9 +- .../p4oc/ui/screens/chat/ChatViewModel.kt | 90 +----------- .../p4oc/ui/screens/chat/MessageBlockUtils.kt | 37 ++++- app/src/main/res/values/strings.xml | 3 - .../p4oc/ui/screens/chat/ChatViewModelTest.kt | 54 ------- .../ui/screens/chat/MessageBlockUtilsTest.kt | 50 ++++++- 15 files changed, 266 insertions(+), 362 deletions(-) create mode 100644 .tickets/oa-77dh.md create mode 100644 .tickets/oa-cplr.md create mode 100644 .tickets/oa-uahy.md create mode 100644 .tickets/oa-x112.md delete mode 100644 app/src/main/java/dev/blazelight/p4oc/ui/components/chat/QueuedMessagesStrip.kt diff --git a/.tickets/oa-77dh.md b/.tickets/oa-77dh.md new file mode 100644 index 00000000..22428185 --- /dev/null +++ b/.tickets/oa-77dh.md @@ -0,0 +1,41 @@ +--- +id: oa-77dh +status: open +deps: [] +links: [oa-wxf2, oa-12ui, oa-cplr] +created: 2026-07-05T18:07:13Z +type: bug +priority: 1 +assignee: Jasmin Le Roux +parent: oa-nwha +--- +# Preserve chat draft queued messages and attachments per session + +Problem: +Chat draft text, queued messages, and attachment/input state are lifecycle-critical user-authored state. Audit found this state can be plain ViewModel/Compose state and may be lost or incorrectly reused across session/tab recreation. + +Evidence: +Lifecycle audit identified ChatViewModel.kt draft/queued message/attachment state, ChatInputBar.kt input/attachment UI state, and FilePickerManager.kt picker state as restoration-critical. Chat scroll/follow-tail restoration is tracked separately in oa-wxf2; this ticket covers user-authored chat input and pending attachments. + +UX Constraint: +The chat input is the core agent workspace. Losing an unsent prompt or queued attachment after tab switching, rotation, process recreation, or file-picker return is a user-data-loss bug. State must be scoped so one session's draft cannot appear in another session. + +Expected Behavior: +Draft message text, selected/queued attachments, and pending send state restore for the same workspace/session/tab when safe. They remain isolated between different sessions and workspaces. If an attachment file is no longer available, the UI shows a human-readable recovery/removal state. + +Acceptance Criteria: +- Identify the current source of truth for chat draft text, queued sends, selected attachments, and file picker return state. +- Persist or save draft/attachment state keyed by workspace/session/tab identity. +- Ensure switching to another session does not inherit the prior session's draft or attachments. +- Handle missing or inaccessible restored attachments with clear UI instead of silent drop or raw error payload. +- Add behavior/ViewModel tests for same-session restoration, cross-session isolation, and missing attachment recovery where seams exist. + +Verification: +Run targeted ChatViewModel/ChatInputBar/FilePicker tests. Smoke test typing a draft with an attachment, switching away/back, and returning from file picker. + + +## Notes + +**2026-07-06T19:23:04Z** + +Busy follow-up queue scope was resolved by architecture change rather than persistence: Android-local queuedMessages/queueMessage/sendQueuedMessageIfAny/QueuedMessagesStrip were removed, and busy Send now submits upstream immediately. The visible queued state is derived from upstream transcript messages. Remaining unsent draft/attachment lifecycle persistence is split to oa-cplr. diff --git a/.tickets/oa-cplr.md b/.tickets/oa-cplr.md new file mode 100644 index 00000000..22afa35f --- /dev/null +++ b/.tickets/oa-cplr.md @@ -0,0 +1,34 @@ +--- +id: oa-cplr +status: open +deps: [] +links: [oa-77dh] +created: 2026-07-06T19:22:45Z +type: bug +priority: 1 +assignee: Jasmin Le Roux +--- +# Persist unsent chat drafts and attachments per tab + +Problem: +The local busy-message queue has been removed in favor of upstream-submitted follow-ups, but unsent chat composer state remains local UI/ViewModel state. Typed-but-not-sent draft text and selected attachments may still be lost or incorrectly reused across tab/session recreation, process return, or file picker return. + +Evidence: +The busy follow-up refactor deletes ChatUiState.queuedMessages and sends submitted prompts upstream immediately, so queued prompt loss is no longer a local persistence problem. Remaining user-authored state before Send still lives in ChatUiState.inputText and FilePickerManager attached-file state. + +UX Constraint: +The chat composer is core workspace state. Draft text or selected attachments must not leak across sessions/workspaces/tabs, and missing attachment references must produce readable recovery UI rather than silent loss or raw errors. + +Expected Behavior: +Unsent draft text and selected attachments restore for the same tab/session/workspace when safe. Switching to a different session/workspace/tab does not inherit the prior draft or attachments. Missing or inaccessible attachment references show a human-readable unavailable/removable state. + +Acceptance Criteria: +- Identify the current source of truth for unsent draft text, selected attachments, and file picker return state after the busy-follow-up queue refactor. +- Persist or save draft/attachment state keyed by existing tab/session/workspace identity without introducing global/default workspace fallbacks. +- Ensure switching sessions/workspaces/tabs does not leak draft text or attachments. +- Handle missing/inaccessible restored attachments with clear UI and a remove path. +- Add focused behavior/ViewModel tests for same-key restoration, cross-key isolation, and missing attachment recovery where seams exist. + +Verification: +Run targeted ChatViewModel/ChatInputBar/FilePicker tests. Smoke test typing a draft with an attachment, switching away/back, and returning from file picker. + diff --git a/.tickets/oa-uahy.md b/.tickets/oa-uahy.md new file mode 100644 index 00000000..ddebb1c3 --- /dev/null +++ b/.tickets/oa-uahy.md @@ -0,0 +1,33 @@ +--- +id: oa-uahy +status: open +deps: [] +links: [] +created: 2026-07-06T19:23:41Z +type: bug +priority: 2 +assignee: Jasmin Le Roux +--- +# Server URL field can append pasted input into existing value + +Problem: +On the Connect screen, the Server URL text field can keep an old cursor/value and append a newly typed/pasted URL into the middle of the existing URL, producing malformed connection targets. + +Evidence: +During debug-app ADB testing after selecting/typing the same discovered server, the Server URL field showed a malformed value like: http://192.1http://192.168.24.25:409668.24.25... This happened while trying to replace the URL with http://192.168.24.25:4096. + +UX Constraint: +Server connection setup must make wrong-target mistakes obvious and avoid corrupting the primary endpoint field. Users should be able to select a discovered server or replace a manual URL without needing to manually clear hidden prior text/cursor state. + +Expected Behavior: +Selecting a discovered server or entering a new URL replaces the field contents cleanly, keeps the cursor at the end, and validates the final normalized URL before connect. A malformed URL should produce a readable validation error before attempting network fallback. + +Acceptance Criteria: +- Selecting a discovered server replaces the Server URL field rather than appending into it. +- Manual paste/typing after a failed connection can cleanly replace the full prior URL. +- Connect validates the normalized URL and shows a readable malformed-URL error before network attempts. +- Regression coverage for replacing an existing URL value with a discovered/manual URL. + +Verification: +On debug build, connect screen: fail one connection, select discovered server, then manually replace URL; confirm the field contains exactly one normalized URL and Connect uses that URL. + diff --git a/.tickets/oa-x112.md b/.tickets/oa-x112.md new file mode 100644 index 00000000..594e5e43 --- /dev/null +++ b/.tickets/oa-x112.md @@ -0,0 +1,33 @@ +--- +id: oa-x112 +status: open +deps: [] +links: [] +created: 2026-07-06T18:07:09Z +type: bug +priority: 2 +assignee: Jasmin Le Roux +--- +# Connect failure reports fallback port instead of entered server URL + +Problem: +When connecting to a manually-entered OpenCode server URL with an explicit/default OpenCode port, the app can display only the fallback port failure. In live debug testing, entering http://192.168.24.25:4096 resulted in the Connect screen showing: Failed to connect to /192.168.24.25:80. + +Evidence: +ConnectionManager tries connectionCandidates(config) and appends an http :80 fallback when the parsed port is ServerUrl.DEFAULT_PORT. If both candidates fail, the surfaced error can be the fallback candidate rather than the user-entered URL/primary candidate. This misleads users into thinking the app ignored their 4096 input. + +UX Constraint: +Connection errors must be human-readable and must not surface misleading fallback-only endpoints. If fallback probing is attempted, the user should still see the primary entered URL and enough context to diagnose network/bind issues. + +Expected Behavior: +Manual connect failures should report the entered/normalized URL first. Fallback attempts may be mentioned secondarily, but must not replace the primary failure. + +Acceptance Criteria: +- Connecting to http://host:4096 when no server is reachable reports host:4096, not only host:80. +- If fallback probing is retained, error text indicates both primary and fallback attempts or prioritizes the primary error. +- Explicit non-default ports are never rewritten in user-facing errors. +- No raw stack traces or protocol internals appear in the Connect screen. + +Verification: +Test with a reachable host but closed 4096 port and confirm the error references the entered 4096 URL. Test default host without explicit port if fallback behavior is intended. + diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml index 5fa279e5..3c75c124 100644 --- a/app/detekt-baseline.xml +++ b/app/detekt-baseline.xml @@ -6,8 +6,7 @@ ArgumentListWrapping:TabNavHost.kt$(backStackEntry, navController, workspaceRoute, workspaceOwner, backStackEntry.destination.route) ArgumentListWrapping:ToolGroupWidget.kt$(pendingPermissionIdsByCallId[tool.callID] ?: tool.callID) CommentWrapping:SoraCodeEditorView.kt$/* autoComplete = */ - ComplexCondition:ChatScreen.kt$!didInitialTailScroll && !uiState.isLoading && (messages.isNotEmpty() || pendingQuestionId != null) - CyclomaticComplexMethod:ChatInputBar.kt$@Composable fun ChatInputBar( value: String, onValueChange: (String) -> Unit, onSend: () -> Unit, isLoading: Boolean, enabled: Boolean, modifier: Modifier = Modifier, isBusy: Boolean = false, queuedCount: Int = 0, onQueueMessage: () -> Unit = {}, onAbort: () -> Unit = {}, attachedFiles: List<SelectedFile> = emptyList(), onAttachClick: () -> Unit = {}, onRemoveAttachment: (String) -> Unit = {}, commands: List<Command> = emptyList(), isLoadingCommands: Boolean = false, commandLoadError: String? = null, onRetryCommands: () -> Unit = {}, onCommandSelected: (Command) -> Unit = {}, requestFocus: Boolean = false, enterToSend: Boolean = false, ) + CyclomaticComplexMethod:ChatInputBar.kt$@Composable fun ChatInputBar( value: String, onValueChange: (String) -> Unit, onSend: () -> Unit, isLoading: Boolean, enabled: Boolean, modifier: Modifier = Modifier, isBusy: Boolean = false, onAbort: () -> Unit = {}, attachedFiles: List<SelectedFile> = emptyList(), onAttachClick: () -> Unit = {}, onRemoveAttachment: (String) -> Unit = {}, commands: List<Command> = emptyList(), isLoadingCommands: Boolean = false, commandLoadError: String? = null, onRetryCommands: () -> Unit = {}, onCommandSelected: (Command) -> Unit = {}, requestFocus: Boolean = false, enterToSend: Boolean = false, ) CyclomaticComplexMethod:ChatMessage.kt$@Composable private fun AssistantMessageContent( messageWithParts: MessageWithParts, onToolApprove: (String) -> Unit, onToolDeny: (String) -> Unit, onToolAlways: (String) -> Unit, onOpenSubSession: ((String) -> Unit)? = null, defaultToolWidgetState: ToolWidgetState = ToolWidgetState.COMPACT, pendingPermissionsByCallId: Map<String, Permission> = emptyMap(), ) CyclomaticComplexMethod:ChatScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatScreen( viewModel: ChatViewModel = koinViewModel(), onNavigateBack: () -> Unit, onOpenTerminal: () -> Unit, onOpenFiles: () -> Unit, onViewSessionDiff: ((String) -> Unit)? = null, onOpenSubSession: ((String) -> Unit)? = null, onSessionLoaded: ((sessionId: String, sessionTitle: String) -> Unit)? = null, onConnectionStateChanged: ((SessionConnectionState?) -> Unit)? = null, isActiveTab: Boolean = true ) CyclomaticComplexMethod:DiffViewerScreen.kt$@Composable private fun SideBySideDiffView( files: List<ParsedFileDiff>, modifier: Modifier = Modifier ) @@ -48,12 +47,12 @@ FunctionNaming:AgentsConfigScreen.kt$@Composable private fun EmptyAgentsView() FunctionNaming:AgentsConfigScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun AgentsConfigScreen( viewModel: AgentsConfigViewModel = koinViewModel(), onNavigateBack: () -> Unit ) FunctionNaming:AgentsConfigScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable private fun AgentCard( agent: AgentInfo, onClick: () -> Unit ) - FunctionNaming:ChatInputBar.kt$@Composable fun ChatInputBar( value: String, onValueChange: (String) -> Unit, onSend: () -> Unit, isLoading: Boolean, enabled: Boolean, modifier: Modifier = Modifier, isBusy: Boolean = false, queuedCount: Int = 0, onQueueMessage: () -> Unit = {}, onAbort: () -> Unit = {}, attachedFiles: List<SelectedFile> = emptyList(), onAttachClick: () -> Unit = {}, onRemoveAttachment: (String) -> Unit = {}, commands: List<Command> = emptyList(), isLoadingCommands: Boolean = false, commandLoadError: String? = null, onRetryCommands: () -> Unit = {}, onCommandSelected: (Command) -> Unit = {}, requestFocus: Boolean = false, enterToSend: Boolean = false, ) + FunctionNaming:ChatInputBar.kt$@Composable fun ChatInputBar( value: String, onValueChange: (String) -> Unit, onSend: () -> Unit, isLoading: Boolean, enabled: Boolean, modifier: Modifier = Modifier, isBusy: Boolean = false, onAbort: () -> Unit = {}, attachedFiles: List<SelectedFile> = emptyList(), onAttachClick: () -> Unit = {}, onRemoveAttachment: (String) -> Unit = {}, commands: List<Command> = emptyList(), isLoadingCommands: Boolean = false, commandLoadError: String? = null, onRetryCommands: () -> Unit = {}, onCommandSelected: (Command) -> Unit = {}, requestFocus: Boolean = false, enterToSend: Boolean = false, ) FunctionNaming:ChatMessage.kt$@Composable @OptIn(ExperimentalFoundationApi::class) private fun ReasoningPart(part: Part.Reasoning) FunctionNaming:ChatMessage.kt$@Composable @OptIn(ExperimentalFoundationApi::class) private fun TextPart(part: Part.Text) - FunctionNaming:ChatMessage.kt$@Composable @OptIn(ExperimentalFoundationApi::class) private fun UserMessage( messageWithParts: MessageWithParts, onRevert: (() -> Unit)? = null, ) + FunctionNaming:ChatMessage.kt$@Composable @OptIn(ExperimentalFoundationApi::class) private fun UserMessage( messageWithParts: MessageWithParts, onRevert: (() -> Unit)? = null, isQueued: Boolean = false, ) FunctionNaming:ChatMessage.kt$@Composable fun AssistantMessages( messagesWithParts: List<MessageWithParts>, onToolApprove: (String) -> Unit, onToolDeny: (String) -> Unit, onToolAlways: (String) -> Unit, onOpenSubSession: ((String) -> Unit)? = null, defaultToolWidgetState: ToolWidgetState = ToolWidgetState.COMPACT, pendingPermissionsByCallId: Map<String, Permission> = emptyMap(), modifier: Modifier = Modifier ) - FunctionNaming:ChatMessage.kt$@Composable fun ChatMessage( messageWithParts: MessageWithParts, onToolApprove: (String) -> Unit, onToolDeny: (String) -> Unit, onToolAlways: (String) -> Unit, onOpenSubSession: ((String) -> Unit)? = null, defaultToolWidgetState: ToolWidgetState = ToolWidgetState.COMPACT, pendingPermissionsByCallId: Map<String, Permission> = emptyMap(), onRevert: (() -> Unit)? = null, modifier: Modifier = Modifier ) + FunctionNaming:ChatMessage.kt$@Composable fun ChatMessage( messageWithParts: MessageWithParts, onToolApprove: (String) -> Unit, onToolDeny: (String) -> Unit, onToolAlways: (String) -> Unit, onOpenSubSession: ((String) -> Unit)? = null, defaultToolWidgetState: ToolWidgetState = ToolWidgetState.COMPACT, pendingPermissionsByCallId: Map<String, Permission> = emptyMap(), onRevert: (() -> Unit)? = null, isQueued: Boolean = false, modifier: Modifier = Modifier, ) FunctionNaming:ChatMessage.kt$@Composable private fun AssistantError(error: MessageError) FunctionNaming:ChatMessage.kt$@Composable private fun AssistantMessageContent( messageWithParts: MessageWithParts, onToolApprove: (String) -> Unit, onToolDeny: (String) -> Unit, onToolAlways: (String) -> Unit, onOpenSubSession: ((String) -> Unit)? = null, defaultToolWidgetState: ToolWidgetState = ToolWidgetState.COMPACT, pendingPermissionsByCallId: Map<String, Permission> = emptyMap(), ) FunctionNaming:ChatMessage.kt$@Composable private fun CompactPatchPart(part: Part.Patch) @@ -78,7 +77,6 @@ FunctionNaming:ComponentPreviews.kt$@Preview(name = "Git Status Badges", showBackground = true) @Composable private fun GitStatusBadgesPreview() FunctionNaming:ComponentPreviews.kt$@Preview(name = "Loading State", showBackground = true) @Composable private fun LoadingStatePreview() FunctionNaming:ComponentPreviews.kt$@Preview(name = "Project Chip", showBackground = true) @Composable private fun ProjectChipPreview() - FunctionNaming:ComponentPreviews.kt$@Preview(name = "Queued Messages Strip", showBackground = true) @Composable private fun QueuedMessagesStripPreview() FunctionNaming:ComponentPreviews.kt$@Preview(name = "Session Card", showBackground = true) @Composable private fun SessionCardPreview( @PreviewParameter(SessionPreviewProvider::class) session: Session ) FunctionNaming:ComponentPreviews.kt$@Preview(name = "Settings Item", showBackground = true) @Composable private fun SettingsItemPreview() FunctionNaming:ComponentPreviews.kt$@Preview(name = "Todo Item", showBackground = true) @Composable private fun TodoItemPreview() @@ -150,7 +148,6 @@ FunctionNaming:ProviderConfigScreen.kt$@Composable private fun ProviderCard( provider: ProviderDto, isExpanded: Boolean, currentModel: String?, onToggle: () -> Unit, onSelectModel: (String) -> Unit ) FunctionNaming:ProviderConfigScreen.kt$@Composable private fun ProviderIcon(providerName: String, alpha: Float = 1f) FunctionNaming:ProviderConfigScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ProviderConfigScreen( viewModel: ProviderConfigViewModel = koinViewModel(), onNavigateBack: () -> Unit ) - FunctionNaming:QueuedMessagesStrip.kt$@Composable fun QueuedMessagesStrip( queuedMessages: List<QueuedMessage>, onCancel: (String) -> Unit, modifier: Modifier = Modifier ) FunctionNaming:ServerScreen.kt$@Composable private fun DiscoveredServersSection( servers: List<DiscoveredServer>, discoveryState: DiscoveryState, isConnecting: Boolean, onServerClick: (DiscoveredServer) -> Unit ) FunctionNaming:ServerScreen.kt$@Composable private fun RecentServersSection( servers: List<RecentServer>, isConnecting: Boolean, onServerClick: (RecentServer) -> Unit, onRemoveServer: (RecentServer) -> Unit ) FunctionNaming:ServerScreen.kt$@Composable private fun RemoteServerSection( url: String, username: String, password: String, allowInsecure: Boolean, isConnecting: Boolean, onUrlChange: (String) -> Unit, onUsernameChange: (String) -> Unit, onPasswordChange: (String) -> Unit, onAllowInsecureChange: (Boolean) -> Unit, onConnect: () -> Unit ) @@ -265,16 +262,15 @@ FunctionNaming:VisualSettingsScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ThemeSelector( selected: String, options: List<Pair<String, String>>, onSelect: (String) -> Unit ) FunctionNaming:VisualSettingsScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ToolWidgetStateSelector( selected: String, onSelect: (String) -> Unit ) ImportOrdering:ConnectionManager.kt$import dev.blazelight.p4oc.BuildConfig import dev.blazelight.p4oc.core.datastore.SettingsDataStore import dev.blazelight.p4oc.core.log.AppLog import dev.blazelight.p4oc.data.remote.dto.ProjectDto import dev.blazelight.p4oc.data.remote.mapper.EventMapper import dev.blazelight.p4oc.domain.server.ScopedEvent import dev.blazelight.p4oc.domain.server.ServerGeneration import dev.blazelight.p4oc.domain.server.ServerRef import dev.blazelight.p4oc.domain.server.WorkspaceKey import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import kotlinx.serialization.json.Json import okhttp3.ConnectionPool import okhttp3.Credentials import okhttp3.Dispatcher import okhttp3.Interceptor import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit import retrofit2.converter.kotlinx.serialization.asConverterFactory import java.util.concurrent.TimeUnit - ImportOrdering:SessionRepositoryImpl.kt$import dev.blazelight.p4oc.core.log.AppLog import dev.blazelight.p4oc.data.files.ofish.OfishSessionNames import dev.blazelight.p4oc.data.remote.dto.CreateSessionRequest import dev.blazelight.p4oc.data.remote.dto.ProjectDto import dev.blazelight.p4oc.data.remote.dto.QuestionRequestDto import dev.blazelight.p4oc.data.remote.dto.SendMessageRequest import dev.blazelight.p4oc.data.remote.dto.UpdateSessionRequest import dev.blazelight.p4oc.data.remote.mapper.MessageMapper import dev.blazelight.p4oc.data.remote.mapper.PermissionMapper import dev.blazelight.p4oc.data.remote.mapper.SessionMapper import dev.blazelight.p4oc.data.remote.mapper.mapQuestionRequestDtoToDomain import dev.blazelight.p4oc.data.workspace.SessionWorkspaceClient import dev.blazelight.p4oc.data.workspace.WorkspaceClient import dev.blazelight.p4oc.domain.model.Message import dev.blazelight.p4oc.domain.model.MessageWithParts import dev.blazelight.p4oc.domain.model.OpenCodeEvent import dev.blazelight.p4oc.domain.model.Part import dev.blazelight.p4oc.domain.model.Session import dev.blazelight.p4oc.domain.model.SessionStatus import dev.blazelight.p4oc.domain.model.TokenUsage import dev.blazelight.p4oc.domain.model.ToolState import dev.blazelight.p4oc.domain.model.isQuestionTool import dev.blazelight.p4oc.domain.session.SessionId import dev.blazelight.p4oc.domain.session.WorkspaceSession import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.cancel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import java.util.concurrent.atomic.AtomicInteger import kotlin.coroutines.coroutineContext ImportOrdering:SlashCommandsPopup.kt$import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupPositionProvider import androidx.compose.ui.window.PopupProperties import dev.blazelight.p4oc.domain.model.Command import dev.blazelight.p4oc.domain.model.CommandSource import dev.blazelight.p4oc.ui.theme.LocalOpenCodeTheme import dev.blazelight.p4oc.ui.theme.Sizing import dev.blazelight.p4oc.ui.theme.Spacing Indentation:ChatScreen.kt$ InstanceOfCheckForException:DialogQueueManager.kt$DialogQueueManager$e is CancellationException LargeClass:SessionRepositoryImpl.kt$SessionRepositoryImpl : SessionRepository LongMethod:AgentsConfigScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun AgentsConfigScreen( viewModel: AgentsConfigViewModel = koinViewModel(), onNavigateBack: () -> Unit ) LongMethod:AgentsConfigScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable private fun AgentCard( agent: AgentInfo, onClick: () -> Unit ) - LongMethod:ChatInputBar.kt$@Composable fun ChatInputBar( value: String, onValueChange: (String) -> Unit, onSend: () -> Unit, isLoading: Boolean, enabled: Boolean, modifier: Modifier = Modifier, isBusy: Boolean = false, queuedCount: Int = 0, onQueueMessage: () -> Unit = {}, onAbort: () -> Unit = {}, attachedFiles: List<SelectedFile> = emptyList(), onAttachClick: () -> Unit = {}, onRemoveAttachment: (String) -> Unit = {}, commands: List<Command> = emptyList(), isLoadingCommands: Boolean = false, commandLoadError: String? = null, onRetryCommands: () -> Unit = {}, onCommandSelected: (Command) -> Unit = {}, requestFocus: Boolean = false, enterToSend: Boolean = false, ) + LongMethod:ChatInputBar.kt$@Composable fun ChatInputBar( value: String, onValueChange: (String) -> Unit, onSend: () -> Unit, isLoading: Boolean, enabled: Boolean, modifier: Modifier = Modifier, isBusy: Boolean = false, onAbort: () -> Unit = {}, attachedFiles: List<SelectedFile> = emptyList(), onAttachClick: () -> Unit = {}, onRemoveAttachment: (String) -> Unit = {}, commands: List<Command> = emptyList(), isLoadingCommands: Boolean = false, commandLoadError: String? = null, onRetryCommands: () -> Unit = {}, onCommandSelected: (Command) -> Unit = {}, requestFocus: Boolean = false, enterToSend: Boolean = false, ) LongMethod:ChatMessage.kt$@Composable @OptIn(ExperimentalFoundationApi::class) private fun ReasoningPart(part: Part.Reasoning) - LongMethod:ChatMessage.kt$@Composable @OptIn(ExperimentalFoundationApi::class) private fun UserMessage( messageWithParts: MessageWithParts, onRevert: (() -> Unit)? = null, ) + LongMethod:ChatMessage.kt$@Composable @OptIn(ExperimentalFoundationApi::class) private fun UserMessage( messageWithParts: MessageWithParts, onRevert: (() -> Unit)? = null, isQueued: Boolean = false, ) LongMethod:ChatScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatScreen( viewModel: ChatViewModel = koinViewModel(), onNavigateBack: () -> Unit, onOpenTerminal: () -> Unit, onOpenFiles: () -> Unit, onViewSessionDiff: ((String) -> Unit)? = null, onOpenSubSession: ((String) -> Unit)? = null, onSessionLoaded: ((sessionId: String, sessionTitle: String) -> Unit)? = null, onConnectionStateChanged: ((SessionConnectionState?) -> Unit)? = null, isActiveTab: Boolean = true ) LongMethod:ChatScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ChatTopBar( title: String, connectionState: ConnectionState, onBack: () -> Unit, onTerminal: () -> Unit, onFiles: () -> Unit, onSearch: () -> Unit, onCommands: () -> Unit, onViewChanges: () -> Unit, branchName: String? = null, todoCount: Int = 0, onTodos: () -> Unit = {} ) LongMethod:ChatSearchBar.kt$@Composable internal fun ChatSearchBar( query: String, onQueryChange: (String) -> Unit, matchCount: Int, currentIndex: Int, onPrev: () -> Unit, onNext: () -> Unit, onClose: () -> Unit, modifier: Modifier = Modifier, ) @@ -316,7 +312,6 @@ LongMethod:ProviderConfigScreen.kt$@Composable private fun ModelItem( model: ModelDto, isSelected: Boolean, onClick: () -> Unit ) LongMethod:ProviderConfigScreen.kt$@Composable private fun ProviderCard( provider: ProviderDto, isExpanded: Boolean, currentModel: String?, onToggle: () -> Unit, onSelectModel: (String) -> Unit ) LongMethod:ProviderConfigScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ProviderConfigScreen( viewModel: ProviderConfigViewModel = koinViewModel(), onNavigateBack: () -> Unit ) - LongMethod:QueuedMessagesStrip.kt$@Composable fun QueuedMessagesStrip( queuedMessages: List<QueuedMessage>, onCancel: (String) -> Unit, modifier: Modifier = Modifier ) LongMethod:ServerScreen.kt$@Composable private fun DiscoveredServersSection( servers: List<DiscoveredServer>, discoveryState: DiscoveryState, isConnecting: Boolean, onServerClick: (DiscoveredServer) -> Unit ) LongMethod:ServerScreen.kt$@Composable private fun RemoteServerSection( url: String, username: String, password: String, allowInsecure: Boolean, isConnecting: Boolean, onUrlChange: (String) -> Unit, onUsernameChange: (String) -> Unit, onPasswordChange: (String) -> Unit, onAllowInsecureChange: (Boolean) -> Unit, onConnect: () -> Unit ) LongMethod:ServerScreen.kt$@Composable private fun ServerSetupHelpSection() @@ -349,9 +344,9 @@ LongMethod:UploadProgressSheet.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun UploadProgressSheet( state: UploadQueueState, onCancel: () -> Unit, onDismiss: () -> Unit, onRetryFailed: () -> Unit, ) LongMethod:VisualSettingsScreen.kt$@Composable private fun ToolWidgetPreviewSection(selectedState: String) LongMethod:VisualSettingsScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun VisualSettingsScreen( viewModel: VisualSettingsViewModel = koinViewModel(), onNavigateBack: () -> Unit ) - LongParameterList:ChatInputBar.kt$( value: String, onValueChange: (String) -> Unit, onSend: () -> Unit, isLoading: Boolean, enabled: Boolean, modifier: Modifier = Modifier, isBusy: Boolean = false, queuedCount: Int = 0, onQueueMessage: () -> Unit = {}, onAbort: () -> Unit = {}, attachedFiles: List<SelectedFile> = emptyList(), onAttachClick: () -> Unit = {}, onRemoveAttachment: (String) -> Unit = {}, commands: List<Command> = emptyList(), isLoadingCommands: Boolean = false, commandLoadError: String? = null, onRetryCommands: () -> Unit = {}, onCommandSelected: (Command) -> Unit = {}, requestFocus: Boolean = false, enterToSend: Boolean = false, ) + LongParameterList:ChatInputBar.kt$( value: String, onValueChange: (String) -> Unit, onSend: () -> Unit, isLoading: Boolean, enabled: Boolean, modifier: Modifier = Modifier, isBusy: Boolean = false, onAbort: () -> Unit = {}, attachedFiles: List<SelectedFile> = emptyList(), onAttachClick: () -> Unit = {}, onRemoveAttachment: (String) -> Unit = {}, commands: List<Command> = emptyList(), isLoadingCommands: Boolean = false, commandLoadError: String? = null, onRetryCommands: () -> Unit = {}, onCommandSelected: (Command) -> Unit = {}, requestFocus: Boolean = false, enterToSend: Boolean = false, ) LongParameterList:ChatMessage.kt$( messageWithParts: MessageWithParts, onToolApprove: (String) -> Unit, onToolDeny: (String) -> Unit, onToolAlways: (String) -> Unit, onOpenSubSession: ((String) -> Unit)? = null, defaultToolWidgetState: ToolWidgetState = ToolWidgetState.COMPACT, pendingPermissionsByCallId: Map<String, Permission> = emptyMap(), ) - LongParameterList:ChatMessage.kt$( messageWithParts: MessageWithParts, onToolApprove: (String) -> Unit, onToolDeny: (String) -> Unit, onToolAlways: (String) -> Unit, onOpenSubSession: ((String) -> Unit)? = null, defaultToolWidgetState: ToolWidgetState = ToolWidgetState.COMPACT, pendingPermissionsByCallId: Map<String, Permission> = emptyMap(), onRevert: (() -> Unit)? = null, modifier: Modifier = Modifier ) + LongParameterList:ChatMessage.kt$( messageWithParts: MessageWithParts, onToolApprove: (String) -> Unit, onToolDeny: (String) -> Unit, onToolAlways: (String) -> Unit, onOpenSubSession: ((String) -> Unit)? = null, defaultToolWidgetState: ToolWidgetState = ToolWidgetState.COMPACT, pendingPermissionsByCallId: Map<String, Permission> = emptyMap(), onRevert: (() -> Unit)? = null, isQueued: Boolean = false, modifier: Modifier = Modifier, ) LongParameterList:ChatMessage.kt$( messagesWithParts: List<MessageWithParts>, onToolApprove: (String) -> Unit, onToolDeny: (String) -> Unit, onToolAlways: (String) -> Unit, onOpenSubSession: ((String) -> Unit)? = null, defaultToolWidgetState: ToolWidgetState = ToolWidgetState.COMPACT, pendingPermissionsByCallId: Map<String, Permission> = emptyMap(), modifier: Modifier = Modifier ) LongParameterList:ChatScreen.kt$( title: String, connectionState: ConnectionState, onBack: () -> Unit, onTerminal: () -> Unit, onFiles: () -> Unit, onSearch: () -> Unit, onCommands: () -> Unit, onViewChanges: () -> Unit, branchName: String? = null, todoCount: Int = 0, onTodos: () -> Unit = {} ) LongParameterList:ChatScreen.kt$( viewModel: ChatViewModel = koinViewModel(), onNavigateBack: () -> Unit, onOpenTerminal: () -> Unit, onOpenFiles: () -> Unit, onViewSessionDiff: ((String) -> Unit)? = null, onOpenSubSession: ((String) -> Unit)? = null, onSessionLoaded: ((sessionId: String, sessionTitle: String) -> Unit)? = null, onConnectionStateChanged: ((SessionConnectionState?) -> Unit)? = null, isActiveTab: Boolean = true ) @@ -403,7 +398,6 @@ LoopWithTooManyJumpStatements:StreamingMarkdown.kt$while MagicNumber:AgentsConfigScreen.kt$3 MagicNumber:AgentsConfigScreen.kt$500 - MagicNumber:ChatInputBar.kt$10 MagicNumber:ChatViewModel.kt$ChatViewModel$404 MagicNumber:ChatViewModel.kt$ChatViewModel$5000 MagicNumber:ConnectionManager.kt$ConnectionManager$1000L @@ -651,10 +645,11 @@ NestedBlockDepth:OfishMutationClient.kt$OfishMutationClient$private suspend fun uploadInSession( sessionId: String, path: String, request: FileUploadRequest, capabilities: OfishCapabilities, ): FileOperationResult<FileUploadResult> NestedBlockDepth:StreamingMarkdown.kt$internal fun parseMarkdownBlocks(text: String): List<MarkdownBlock> NoBlankLineBeforeRbrace:VisualSettingsScreen.kt$ + NoConsecutiveBlankLines:ChatViewModelTest.kt$ChatViewModelTest$ + NoConsecutiveBlankLines:ComponentPreviews.kt$ NoSemicolons:CommandPalette.kt$; NoSemicolons:TextMateAnnotatedStringTest.kt$TextMateAnnotatedStringTest.Companion$; NoTrailingSpaces:ToolGroupWidget.kt$ - NoUnusedImports:SessionRepositoryImpl.kt$import kotlinx.coroutines.launch NoWildcardImports:AgentsConfigScreen.kt$import androidx.compose.foundation.layout.* NoWildcardImports:AgentsConfigScreen.kt$import androidx.compose.material.icons.filled.* NoWildcardImports:AgentsConfigScreen.kt$import androidx.compose.material3.* @@ -891,6 +886,7 @@ TooManyFunctions:TuiComponents.kt$dev.blazelight.p4oc.ui.components.TuiComponents.kt TooManyFunctions:WorkspaceClient.kt$WorkspaceClient : SessionWorkspaceClient UnusedParameter:ChatMessage.kt$onToolAlways: (String) -> Unit + UnusedPrivateProperty:ChatViewModel.kt$ChatViewModel.Companion$private const val MAX_QUEUED_MESSAGES = 10 UseCheckOrError:ChatViewModel.kt$ChatViewModel$throw IllegalStateException("Cannot attach workspace file without a workspace directory") UseCheckOrError:ConnectionManager.kt$ConnectionManager$throw IllegalStateException("Not connected to any server") UseCheckOrError:UploadOrchestratorTest.kt$FakeUploadSource$throw IllegalStateException("no payload for $sourceId") diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ChatInputBar.kt b/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ChatInputBar.kt index 39524637..7f8a5da4 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ChatInputBar.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ChatInputBar.kt @@ -63,8 +63,6 @@ fun ChatInputBar( enabled: Boolean, modifier: Modifier = Modifier, isBusy: Boolean = false, - queuedCount: Int = 0, - onQueueMessage: () -> Unit = {}, onAbort: () -> Unit = {}, attachedFiles: List = emptyList(), onAttachClick: () -> Unit = {}, @@ -108,22 +106,16 @@ fun ChatInputBar( // Determine button state val currentText = textState.text.toString() val hasContent = currentText.isNotBlank() || attachedFiles.isNotEmpty() - val queueIsFull = queuedCount >= 10 - val canSend = hasContent && enabled && !isLoading && !isBusy - val canQueue = hasContent && enabled && isBusy && !queueIsFull + val canSubmit = hasContent && enabled && !isLoading val loadingDescription = stringResource(R.string.cd_loading) val sendDescription = stringResource(R.string.chat_action_send) - val queueDescription = stringResource(R.string.chat_action_queue) - val queueFullDescription = stringResource(R.string.chat_action_queue_full) val disconnectedDescription = stringResource(R.string.chat_disabled_disconnected) val emptyDescription = stringResource(R.string.chat_disabled_empty) val attachDescription = stringResource(R.string.chat_action_attach) val stopDescription = stringResource(R.string.chat_action_stop) val sendContentDescription = when { isLoading -> loadingDescription - canSend -> sendDescription - canQueue -> queueDescription - queueIsFull -> queueFullDescription + canSubmit -> sendDescription !enabled -> disconnectedDescription else -> emptyDescription } @@ -162,16 +154,11 @@ fun ChatInputBar( fun submitFromEnter(): Boolean = when { showSlashCommands -> selectActiveCommand() - canSend -> { + canSubmit -> { onSend() clearInput() true } - canQueue -> { - onQueueMessage() - clearInput() - true - } else -> false } @@ -355,15 +342,12 @@ fun ChatInputBar( IconButton( onClick = { - when { - canSend -> onSend() - canQueue -> onQueueMessage() - else -> return@IconButton - } + if (!canSubmit) return@IconButton + onSend() clearInput() focusRequester.requestFocus() }, - enabled = canSend || canQueue, + enabled = canSubmit, modifier = Modifier .size(Sizing.iconButtonMd) .semantics { contentDescription = sendContentDescription } @@ -374,7 +358,7 @@ fun ChatInputBar( } else { Text( text = "↑", - color = if (canSend || canQueue) theme.accent else theme.textMuted, + color = if (canSubmit) theme.accent else theme.textMuted, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.titleMedium ) diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ChatMessage.kt b/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ChatMessage.kt index a83905ba..cb7c8773 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ChatMessage.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ChatMessage.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import dev.blazelight.p4oc.R import dev.blazelight.p4oc.domain.model.* @@ -41,7 +42,8 @@ fun ChatMessage( defaultToolWidgetState: ToolWidgetState = ToolWidgetState.COMPACT, pendingPermissionsByCallId: Map = emptyMap(), onRevert: (() -> Unit)? = null, - modifier: Modifier = Modifier + isQueued: Boolean = false, + modifier: Modifier = Modifier, ) { val message = messageWithParts.message val isUser = message is Message.User @@ -50,7 +52,7 @@ fun ChatMessage( modifier = modifier.fillMaxWidth() ) { if (isUser) { - UserMessage(messageWithParts, onRevert = onRevert) + UserMessage(messageWithParts, onRevert = onRevert, isQueued = isQueued) } else { AssistantMessages( messagesWithParts = listOf(messageWithParts), @@ -101,6 +103,7 @@ fun AssistantMessages( private fun UserMessage( messageWithParts: MessageWithParts, onRevert: (() -> Unit)? = null, + isQueued: Boolean = false, ) { val theme = LocalOpenCodeTheme.current val clipboardManager = LocalClipboardManager.current @@ -151,12 +154,26 @@ private fun UserMessage( 0.dp } - StreamingMarkdown( - text = text, + Column( modifier = Modifier .fillMaxWidth() .padding(end = revertEndInset) - ) + ) { + StreamingMarkdown(text = text, modifier = Modifier.fillMaxWidth()) + + if (isQueued) { + Text( + text = stringResource(R.string.chat_queued_prefix), + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = theme.background, + modifier = Modifier + .padding(top = Spacing.xs) + .background(theme.primary, RectangleShape) + .padding(horizontal = Spacing.xs, vertical = Spacing.hairline) + ) + } + } onRevert?.let { revert -> Text( diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/QueuedMessagesStrip.kt b/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/QueuedMessagesStrip.kt deleted file mode 100644 index e819aba0..00000000 --- a/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/QueuedMessagesStrip.kt +++ /dev/null @@ -1,137 +0,0 @@ -package dev.blazelight.p4oc.ui.components.chat - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.rememberScrollState -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.style.TextOverflow -import dev.blazelight.p4oc.R -import dev.blazelight.p4oc.ui.screens.chat.QueuedMessage -import dev.blazelight.p4oc.ui.theme.LocalOpenCodeTheme -import dev.blazelight.p4oc.ui.theme.Sizing -import dev.blazelight.p4oc.ui.theme.Spacing - -@Composable -fun QueuedMessagesStrip( - queuedMessages: List, - onCancel: (String) -> Unit, - modifier: Modifier = Modifier -) { - if (queuedMessages.isEmpty()) return - - val theme = LocalOpenCodeTheme.current - val cancelDescription = stringResource(R.string.chat_action_queue_cancel) - Column( - modifier = modifier - .fillMaxWidth() - .padding(horizontal = Spacing.md, vertical = Spacing.xs), - verticalArrangement = Arrangement.spacedBy(Spacing.xs) - ) { - queuedMessages.forEach { queuedMessage -> - Surface( - shape = RectangleShape, - color = theme.backgroundElement, - modifier = Modifier - .fillMaxWidth() - .border(Sizing.strokeMd, theme.border, RectangleShape) - .testTag("queued_message_${queuedMessage.id}") - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = Spacing.md, vertical = Spacing.sm), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(Spacing.sm) - ) { - Box( - modifier = Modifier - .background(theme.accent.copy(alpha = 0.16f), RectangleShape) - .padding(horizontal = Spacing.sm, vertical = Spacing.xxs) - ) { - Text( - text = stringResource(R.string.chat_queued_prefix), - style = MaterialTheme.typography.labelSmall, - fontFamily = FontFamily.Monospace, - color = theme.accent - ) - } - Text( - text = queuedMessage.text.ifBlank { queuedMessage.attachedFiles.joinToString { it.name } }, - modifier = Modifier.weight(1f), - style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace, - color = theme.textMuted, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - if (queuedMessage.attachedFiles.isNotEmpty()) { - Row( - modifier = Modifier.horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(Spacing.xs) - ) { - queuedMessage.attachedFiles.forEach { file -> - Surface( - shape = RectangleShape, - color = theme.background, - modifier = Modifier.border(Sizing.strokeMd, theme.border, RectangleShape) - ) { - Text( - text = file.name, - modifier = Modifier - .padding(horizontal = Spacing.sm, vertical = Spacing.xxs) - .widthIn(max = Sizing.panelWidthSm), - style = MaterialTheme.typography.labelSmall, - fontFamily = FontFamily.Monospace, - color = theme.textMuted, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } - } - } - Text( - text = "×", - modifier = Modifier - .semantics { - contentDescription = cancelDescription - } - .clickable(role = Role.Button) { onCancel(queuedMessage.id) } - .testTag("queued_message_cancel_${queuedMessage.id}"), - style = MaterialTheme.typography.titleMedium, - fontFamily = FontFamily.Monospace, - color = theme.textMuted - ) - } - } - } - Box( - modifier = Modifier - .fillMaxWidth() - .height(Sizing.dividerThickness) - .background(theme.border) - ) - } -} diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/preview/ComponentPreviews.kt b/app/src/main/java/dev/blazelight/p4oc/ui/preview/ComponentPreviews.kt index cc68ee04..f237b2ce 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/preview/ComponentPreviews.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/preview/ComponentPreviews.kt @@ -12,13 +12,10 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp -import dev.blazelight.p4oc.data.remote.dto.ModelInput import dev.blazelight.p4oc.domain.model.Session import dev.blazelight.p4oc.ui.components.TuiLoadingScreen import dev.blazelight.p4oc.ui.components.chat.ChatInputBar -import dev.blazelight.p4oc.ui.components.chat.QueuedMessagesStrip import dev.blazelight.p4oc.ui.components.chat.SelectedFile -import dev.blazelight.p4oc.ui.screens.chat.QueuedMessage import dev.blazelight.p4oc.ui.theme.LocalOpenCodeTheme import dev.blazelight.p4oc.ui.theme.PocketCodeTheme import dev.blazelight.p4oc.ui.theme.Sizing @@ -260,8 +257,6 @@ private fun ChatInputBarPreview() { isLoading = false, enabled = true, isBusy = true, - queuedCount = 2, - onQueueMessage = {}, onAbort = {}, attachedFiles = listOf( SelectedFile("/tmp/log.txt", "log.txt") @@ -271,25 +266,6 @@ private fun ChatInputBarPreview() { } } -@Preview(name = "Queued Messages Strip", showBackground = true) -@Composable -private fun QueuedMessagesStripPreview() { - PocketCodeTheme { - Surface(tonalElevation = 3.dp) { - QueuedMessagesStrip( - queuedMessages = listOf( - QueuedMessage( - text = "Queue this follow-up once the current task finishes", - attachedFiles = listOf(SelectedFile("/tmp/trace.txt", "trace.txt")), - model = ModelInput(providerID = "anthropic", modelID = "claude-sonnet-4") - ), - QueuedMessage(text = "Also summarize the errors") - ), - onCancel = {} - ) - } - } -} /** * Preview for git status badges diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatScreen.kt index dcfb372d..17f9dcf3 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatScreen.kt @@ -38,7 +38,6 @@ import dev.blazelight.p4oc.ui.components.chat.ChatInputBar import dev.blazelight.p4oc.ui.components.chat.FilePickerDialog import dev.blazelight.p4oc.ui.components.chat.JumpToBottomButton import dev.blazelight.p4oc.ui.components.chat.ModelAgentSelectorBar -import dev.blazelight.p4oc.ui.components.chat.QueuedMessagesStrip import dev.blazelight.p4oc.ui.components.command.CommandPalette import dev.blazelight.p4oc.ui.components.question.InlineQuestionCard import dev.blazelight.p4oc.ui.components.status.SessionStatusDot @@ -145,7 +144,7 @@ fun ChatScreen( uiState.session?.id, saver = ChatScrollRestorationState.Saver ) { ChatScrollRestorationState() } - val messageBlocks = remember(messages) { groupMessagesIntoBlocks(messages) } + val messageBlocks = remember(messages, uiState.isBusy) { groupMessagesIntoBlocks(messages, uiState.isBusy) } val searchMatches = remember(messageBlocks, scrollRestorationState.searchQuery) { findChatMatches(messageBlocks, scrollRestorationState.searchQuery) } @@ -274,10 +273,6 @@ fun ChatScreen( .imePadding() .navigationBarsPadding() ) { - QueuedMessagesStrip( - queuedMessages = uiState.queuedMessages, - onCancel = viewModel::cancelQueuedMessage - ) ModelAgentSelectorBar( availableAgents = availableAgents, selectedAgent = selectedAgent, @@ -303,8 +298,6 @@ fun ChatScreen( isLoading = uiState.isSending, enabled = connectionState is ConnectionState.Connected, isBusy = uiState.isBusy, - queuedCount = uiState.queuedMessages.size, - onQueueMessage = viewModel::queueMessage, onAbort = viewModel::abortSession, attachedFiles = attachedFiles, onAttachClick = { diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModel.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModel.kt index 08473bbb..b5fdac83 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModel.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModel.kt @@ -14,7 +14,6 @@ import dev.blazelight.p4oc.core.network.ConnectionManager import dev.blazelight.p4oc.core.network.ConnectionState import dev.blazelight.p4oc.core.network.safeApiCall import dev.blazelight.p4oc.data.remote.dto.ExecuteCommandRequest -import dev.blazelight.p4oc.data.remote.dto.ModelInput import dev.blazelight.p4oc.data.remote.dto.PartInputDto import dev.blazelight.p4oc.data.remote.dto.PermissionResponseRequest import dev.blazelight.p4oc.data.remote.dto.QuestionReplyRequest @@ -37,7 +36,6 @@ import kotlinx.coroutines.launch import kotlinx.serialization.json.Json import java.io.File import java.net.URI -import java.util.UUID /** * Slim coordinator — delegates to sub-managers for message state, @@ -296,7 +294,6 @@ class ChatViewModel constructor( _hasUnreadResponse.value = !_isActiveTab.value handleResponseCompleted() } - if (!isBusy) sendQueuedMessageIfAny() } } @@ -360,81 +357,6 @@ class ChatViewModel constructor( executeCommand(commandName, arguments) } - fun queueMessage() { - val text = _uiState.value.inputText.trim() - val attachedFiles = filePickerManager.attachedFiles.value - if (text.isEmpty() && attachedFiles.isEmpty()) return - if (_uiState.value.queuedMessages.size >= MAX_QUEUED_MESSAGES) { - AppLog.w(TAG, "queueMessage: Queue full, ignoring new queued message") - return - } - - val selectedAgent = modelAgentManager.selectedAgent.value - val selectedModel = modelAgentManager.selectedModel.value - val selectedVariant = modelAgentManager.currentReasoningEffort() - - _uiState.update { - it.copy( - inputText = "", - queuedMessages = it.queuedMessages + QueuedMessage( - text = text, - attachedFiles = attachedFiles, - agent = selectedAgent, - model = selectedModel, - variant = selectedVariant - ) - ) - } - filePickerManager.clearAttachedFiles() - AppLog.d(TAG, "queueMessage: Queued message with ${text.length} chars, ${attachedFiles.size} files") - } - - fun cancelQueuedMessage(messageId: String) { - _uiState.update { state -> - state.copy(queuedMessages = state.queuedMessages.filterNot { it.id == messageId }) - } - } - - private fun sendQueuedMessageIfAny() { - val queued = _uiState.value.queuedMessages.firstOrNull() ?: return - - AppLog.d(TAG, "sendQueuedMessageIfAny: Sending queued message") - _uiState.update { state -> - state.copy( - queuedMessages = state.queuedMessages.drop(1), - isSending = true - ) - } - - viewModelScope.launch { - val parts = buildPartInputs(queued.text, queued.attachedFiles) - val request = SendMessageRequest( - parts = parts, - agent = queued.agent, - model = queued.model, - variant = queued.variant - ) - - val result = sessionRepository.sendMessageAsync(SessionId(sessionId), request).await().toApiResult() - when (result) { - is ApiResult.Success -> { - _uiState.update { it.copy(isSending = false, isBusy = true) } - AppLog.d(TAG, "sendQueuedMessageIfAny: Queued message sent successfully") - } - is ApiResult.Error -> { - _uiState.update { - it.copy( - isSending = false, - error = "Failed to send queued message: ${result.message}" - ) - } - _uiState.update { state -> state.copy(queuedMessages = listOf(queued) + state.queuedMessages) } - filePickerManager.restoreAttachedFiles(queued.attachedFiles) - } - } - } - } - private fun buildPartInputs(text: String, files: List): List { val parts = mutableListOf() if (text.isNotEmpty()) { @@ -749,15 +671,5 @@ data class ChatUiState( val hasLoadedWorkspaceCommands: Boolean = false, val commandLoadError: String? = null, val todos: List = emptyList(), - val isLoadingTodos: Boolean = false, - val queuedMessages: List = emptyList() -) - -data class QueuedMessage( - val id: String = UUID.randomUUID().toString(), - val text: String, - val attachedFiles: List = emptyList(), - val agent: String? = null, - val model: ModelInput? = null, - val variant: String? = null + val isLoadingTodos: Boolean = false ) diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/MessageBlockUtils.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/MessageBlockUtils.kt index e053566d..94a62e93 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/MessageBlockUtils.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/MessageBlockUtils.kt @@ -18,6 +18,7 @@ internal sealed class MessageBlock { data class UserBlock( val message: MessageWithParts, val revertMessageId: String? = null, + val isQueued: Boolean = false, ) : MessageBlock() data class AssistantBlock(val messages: List) : MessageBlock() } @@ -25,9 +26,10 @@ internal sealed class MessageBlock { /** * Group messages into blocks: user messages standalone, consecutive assistant messages merged. */ -internal fun groupMessagesIntoBlocks(messages: List): List { +internal fun groupMessagesIntoBlocks(messages: List, isBusy: Boolean = false): List { if (messages.isEmpty()) return emptyList() + val queuedUserMessageIds = queuedUserMessageIds(messages, isBusy) val revertTargetsByUserId = revertTargetsByUserId(messages) val blocks = mutableListOf() var i = 0 @@ -36,7 +38,13 @@ internal fun groupMessagesIntoBlocks(messages: List): List): List, isBusy: Boolean): Set { + if (!isBusy) return emptySet() + + val assistantParentIds = messages + .mapNotNull { (it.message as? Message.Assistant)?.parentID } + .toSet() + var hasActiveAssistantBefore = false + val queuedIds = mutableSetOf() + + messages.forEach { messageWithParts -> + when (val message = messageWithParts.message) { + is Message.Assistant -> { + if (message.completedAt == null) hasActiveAssistantBefore = true + } + is Message.User -> { + val hasAssistantChild = message.id in assistantParentIds + if (hasActiveAssistantBefore && !hasAssistantChild) queuedIds += message.id + } + } + } + + return queuedIds +} + private fun revertTargetsByUserId(messages: List): Map = buildMap { messages.forEach { messageWithParts -> val message = messageWithParts.message as? Message.Assistant ?: return@forEach @@ -84,6 +116,7 @@ internal fun MessageBlockView( onRevert = block.revertMessageId?.let { messageId -> onRevert?.let { revert -> { revert(messageId) } } }, + isQueued = block.isQueued, ) } is MessageBlock.AssistantBlock -> { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2734094b..e438be42 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -190,11 +190,8 @@ Type a message or / for commands… Send Send - Queue message Stop session Attach file - Queue full - Cancel queued message Disconnected Message is empty queued diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModelTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModelTest.kt index 7a5e0ce1..5c7da34c 100644 --- a/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModelTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModelTest.kt @@ -431,60 +431,6 @@ class ChatViewModelTest { ) } - @Test - fun queueMessage_appendsToQueue_andClearsInput() = runTest { - val vm = createViewModel() - vm.updateInput("queued text") - - vm.queueMessage() - - assertEquals("", vm.uiState.value.inputText) - assertEquals(listOf("queued text"), vm.uiState.value.queuedMessages.map { it.text }) - } - - @Test - fun queueMessage_preservesFifoOrder() = runTest { - val vm = createViewModel() - - vm.updateInput("first") - vm.queueMessage() - vm.updateInput("second") - vm.queueMessage() - vm.updateInput("third") - vm.queueMessage() - - assertEquals(listOf("first", "second", "third"), vm.uiState.value.queuedMessages.map { it.text }) - } - - @Test - fun queueMessage_capsAtTenEntries() = runTest { - val vm = createViewModel() - - repeat(10) { index -> - vm.updateInput("queued-$index") - vm.queueMessage() - } - vm.updateInput("overflow") - vm.queueMessage() - - assertEquals(10, vm.uiState.value.queuedMessages.size) - assertFalse(vm.uiState.value.queuedMessages.any { it.text == "overflow" }) - } - - @Test - fun cancelQueuedMessage_removesMatchingEntry() = runTest { - val vm = createViewModel() - - vm.updateInput("first") - vm.queueMessage() - vm.updateInput("second") - vm.queueMessage() - val cancelId = vm.uiState.value.queuedMessages.first().id - - vm.cancelQueuedMessage(cancelId) - - assertEquals(listOf("second"), vm.uiState.value.queuedMessages.map { it.text }) - } @Test fun abortSession_clearsStreamingFlags_andBusyState() = runTest { diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/MessageBlockUtilsTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/MessageBlockUtilsTest.kt index 536d6e42..b5cf93c3 100644 --- a/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/MessageBlockUtilsTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/MessageBlockUtilsTest.kt @@ -39,13 +39,59 @@ class MessageBlockUtilsTest { assertEquals(listOf(secondAssistant), (blocks[2] as MessageBlock.AssistantBlock).messages) } - private fun assistantMessageWithText(id: String, text: String): MessageWithParts = + @Test + fun groupMessagesIntoBlocks_marksFollowUpBehindActiveAssistantAsQueued() { + val activeAssistant = assistantMessageWithText(id = "assistant-1", text = "working", parentID = "user-1") + val queuedUser = userMessageWithText(id = "user-2", text = "follow-up") + + val blocks = groupMessagesIntoBlocks(listOf(activeAssistant, queuedUser), isBusy = true) + + assertEquals(true, (blocks[1] as MessageBlock.UserBlock).isQueued) + } + + @Test + fun groupMessagesIntoBlocks_doesNotMarkFirstUnansweredUserMessageQueued() { + val user = userMessageWithText(id = "user-1", text = "first") + + val blocks = groupMessagesIntoBlocks(listOf(user), isBusy = true) + + assertEquals(false, (blocks.single() as MessageBlock.UserBlock).isQueued) + } + + @Test + fun groupMessagesIntoBlocks_doesNotMarkQueuedWhenSessionIsIdle() { + val activeAssistant = assistantMessageWithText(id = "assistant-1", text = "working", parentID = "user-1") + val followUp = userMessageWithText(id = "user-2", text = "follow-up") + + val blocks = groupMessagesIntoBlocks(listOf(activeAssistant, followUp), isBusy = false) + + assertEquals(false, (blocks[1] as MessageBlock.UserBlock).isQueued) + } + + @Test + fun groupMessagesIntoBlocks_doesNotMarkUserQueuedWhenAssistantChildExists() { + val activeAssistant = assistantMessageWithText(id = "assistant-1", text = "working", parentID = "user-1") + val followUp = userMessageWithText(id = "user-2", text = "follow-up") + val followUpAssistant = assistantMessageWithText(id = "assistant-2", text = "answer", parentID = "user-2") + + val blocks = groupMessagesIntoBlocks(listOf(activeAssistant, followUp, followUpAssistant), isBusy = true) + + assertEquals(false, (blocks[1] as MessageBlock.UserBlock).isQueued) + } + + private fun assistantMessageWithText( + id: String, + text: String, + parentID: String = "parent-1", + completedAt: Long? = null, + ): MessageWithParts = MessageWithParts( message = Message.Assistant( id = id, sessionID = "session-1", createdAt = 1L, - parentID = "parent-1", + completedAt = completedAt, + parentID = parentID, providerID = "provider-1", modelID = "model-1", mode = "build", From 9495680975c358a40c6043962bf526325d348356 Mon Sep 17 00:00:00 2001 From: Jasmin Le Roux Date: Mon, 6 Jul 2026 21:46:06 +0200 Subject: [PATCH 05/22] Persist unsent chat composer state --- .tickets/oa-cplr.md | 20 +- .tickets/oa-tmpy.md | 33 +++ app/detekt-baseline.xml | 1 - .../ui/components/chat/FilePickerDialog.kt | 2 + .../p4oc/ui/screens/chat/ChatViewModel.kt | 56 +++- .../chat/ChatViewModelDraftPersistenceTest.kt | 241 ++++++++++++++++++ .../p4oc/ui/screens/chat/ChatViewModelTest.kt | 7 +- 7 files changed, 345 insertions(+), 15 deletions(-) create mode 100644 .tickets/oa-tmpy.md create mode 100644 app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModelDraftPersistenceTest.kt diff --git a/.tickets/oa-cplr.md b/.tickets/oa-cplr.md index 22afa35f..fd16d1e3 100644 --- a/.tickets/oa-cplr.md +++ b/.tickets/oa-cplr.md @@ -1,8 +1,8 @@ --- id: oa-cplr -status: open +status: closed deps: [] -links: [oa-77dh] +links: [oa-77dh, oa-tmpy] created: 2026-07-06T19:22:45Z type: bug priority: 1 @@ -17,18 +17,24 @@ Evidence: The busy follow-up refactor deletes ChatUiState.queuedMessages and sends submitted prompts upstream immediately, so queued prompt loss is no longer a local persistence problem. Remaining user-authored state before Send still lives in ChatUiState.inputText and FilePickerManager attached-file state. UX Constraint: -The chat composer is core workspace state. Draft text or selected attachments must not leak across sessions/workspaces/tabs, and missing attachment references must produce readable recovery UI rather than silent loss or raw errors. +The chat composer is core workspace state. Draft text or selected attachments must not leak across sessions/workspaces/tabs. Missing/inaccessible attachment recovery is tracked separately in oa-tmpy. Expected Behavior: -Unsent draft text and selected attachments restore for the same tab/session/workspace when safe. Switching to a different session/workspace/tab does not inherit the prior draft or attachments. Missing or inaccessible attachment references show a human-readable unavailable/removable state. +Unsent draft text and selected attachment references restore for the same tab/session/workspace when safe. Switching to a different session/workspace/tab does not inherit the prior draft or attachments. Unavailable restored attachment recovery is out of scope for this ticket and tracked by oa-tmpy. Acceptance Criteria: - Identify the current source of truth for unsent draft text, selected attachments, and file picker return state after the busy-follow-up queue refactor. - Persist or save draft/attachment state keyed by existing tab/session/workspace identity without introducing global/default workspace fallbacks. - Ensure switching sessions/workspaces/tabs does not leak draft text or attachments. -- Handle missing/inaccessible restored attachments with clear UI and a remove path. -- Add focused behavior/ViewModel tests for same-key restoration, cross-key isolation, and missing attachment recovery where seams exist. +- Add focused behavior/ViewModel tests for same-key restoration, cross-key isolation, and clearing persisted state after successful send. +- Link unavailable restored attachment recovery to oa-tmpy rather than implementing it in this persistence pass. Verification: -Run targeted ChatViewModel/ChatInputBar/FilePicker tests. Smoke test typing a draft with an attachment, switching away/back, and returning from file picker. +Run targeted ChatViewModel draft persistence tests plus compile and detekt. Smoke test typing a draft with an attachment, switching away/back, and returning from file picker. + +## Notes + +**2026-07-06T19:44:07Z** + +Implemented minimal scoped composer persistence using the existing ChatViewModel SavedStateHandle: draft text and selected attachment references are restored for the same chat ViewModel scope, isolated by distinct SavedStateHandles, and cleared after successful send. Missing/inaccessible restored attachment recovery was intentionally not implemented in this pass to avoid overbuilding the persistence layer; follow-up tracked as oa-tmpy. diff --git a/.tickets/oa-tmpy.md b/.tickets/oa-tmpy.md new file mode 100644 index 00000000..d6f12759 --- /dev/null +++ b/.tickets/oa-tmpy.md @@ -0,0 +1,33 @@ +--- +id: oa-tmpy +status: open +deps: [] +links: [oa-cplr] +created: 2026-07-06T19:43:41Z +type: bug +priority: 2 +assignee: Jasmin Le Roux +--- +# Show recovery state for unavailable restored chat attachments + +Problem: +Unsent chat composer attachments can now be restored from SavedStateHandle, but restored attachment references are not validated for availability. If a referenced workspace file was moved, deleted, or became inaccessible, the composer may restore a stale chip and only fail later when sending. + +Evidence: +oa-cplr intentionally implemented the minimal persistence/isolation scope using SavedStateHandle for input text and SelectedFile references. It does not add an unavailable attachment state or validation path. + +UX Constraint: +Attachment recovery must be human-readable and removable. The user should not see raw protocol errors or silently lose restored attachments. + +Expected Behavior: +When restored selected attachments are missing or inaccessible, the composer shows an unavailable/removable attachment state or a clear error before Send. Available attachments continue to send normally. + +Acceptance Criteria: +- Validate restored selected attachment references against the current workspace/session context before send or when the composer restores. +- Missing/inaccessible attachments show clear UI text and a remove action. +- Send does not silently drop stale attachments or surface raw protocol/JSON errors. +- Add focused tests for available restored attachment, unavailable restored attachment, and remove unavailable attachment. + +Verification: +Restore a draft with one existing and one missing attachment; confirm the missing one is visibly recoverable/removable and the existing one still sends. + diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml index 3c75c124..d26ad8dd 100644 --- a/app/detekt-baseline.xml +++ b/app/detekt-baseline.xml @@ -886,7 +886,6 @@ TooManyFunctions:TuiComponents.kt$dev.blazelight.p4oc.ui.components.TuiComponents.kt TooManyFunctions:WorkspaceClient.kt$WorkspaceClient : SessionWorkspaceClient UnusedParameter:ChatMessage.kt$onToolAlways: (String) -> Unit - UnusedPrivateProperty:ChatViewModel.kt$ChatViewModel.Companion$private const val MAX_QUEUED_MESSAGES = 10 UseCheckOrError:ChatViewModel.kt$ChatViewModel$throw IllegalStateException("Cannot attach workspace file without a workspace directory") UseCheckOrError:ConnectionManager.kt$ConnectionManager$throw IllegalStateException("Not connected to any server") UseCheckOrError:UploadOrchestratorTest.kt$FakeUploadSource$throw IllegalStateException("no payload for $sourceId") diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/FilePickerDialog.kt b/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/FilePickerDialog.kt index 21d88033..8212a59e 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/FilePickerDialog.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/FilePickerDialog.kt @@ -35,7 +35,9 @@ import dev.blazelight.p4oc.ui.theme.LocalOpenCodeTheme import dev.blazelight.p4oc.ui.theme.SemanticColors import dev.blazelight.p4oc.ui.theme.Sizing import dev.blazelight.p4oc.ui.theme.Spacing +import kotlinx.serialization.Serializable +@Serializable data class SelectedFile( val path: String, val name: String, diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModel.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModel.kt index b5fdac83..6640e154 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModel.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModel.kt @@ -33,6 +33,7 @@ import dev.blazelight.p4oc.ui.navigation.Screen import dev.blazelight.p4oc.ui.screens.files.upload.UploadCoordinator import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch +import kotlinx.serialization.SerializationException import kotlinx.serialization.json.Json import java.io.File import java.net.URI @@ -70,7 +71,7 @@ class ChatViewModel constructor( val filePickerManager = FilePickerManager(workspaceClient, viewModelScope, uploadCoordinator, settingsDataStore) // --- Core state --- - private val _uiState = MutableStateFlow(ChatUiState()) + private val _uiState = MutableStateFlow(ChatUiState(inputText = restoredInputText())) val uiState: StateFlow = _uiState.asStateFlow() private val _sessionMissing = MutableSharedFlow(replay = 1) val sessionMissing: SharedFlow = _sessionMissing.asSharedFlow() @@ -142,7 +143,8 @@ class ChatViewModel constructor( private companion object { const val TAG = "ChatViewModel" - private const val MAX_QUEUED_MESSAGES = 10 + private const val KEY_DRAFT_TEXT = "chat_draft_text" + private const val KEY_ATTACHED_FILES = "chat_attached_files" /** * Built-in OpenCode commands that aren't returned by the /command API endpoint. @@ -170,7 +172,49 @@ class ChatViewModel constructor( ) } + private fun restoredInputText(): String = savedStateHandle.get(KEY_DRAFT_TEXT).orEmpty() + + private fun restoredAttachedFiles(): List { + val jsonString = savedStateHandle.get(KEY_ATTACHED_FILES) ?: return emptyList() + return try { + json.decodeFromString>(jsonString) + } catch (e: SerializationException) { + AppLog.e(TAG, "Failed to restore attached files", e) + savedStateHandle.remove(KEY_ATTACHED_FILES) + emptyList() + } catch (e: IllegalArgumentException) { + AppLog.e(TAG, "Failed to restore attached files", e) + savedStateHandle.remove(KEY_ATTACHED_FILES) + emptyList() + } + } + + private fun persistInputText(text: String) { + if (text.isEmpty()) { + savedStateHandle.remove(KEY_DRAFT_TEXT) + } else { + savedStateHandle[KEY_DRAFT_TEXT] = text + } + } + + private fun persistAttachedFiles(files: List) { + if (files.isEmpty()) { + savedStateHandle.remove(KEY_ATTACHED_FILES) + } else { + savedStateHandle[KEY_ATTACHED_FILES] = json.encodeToString(files) + } + } + + private fun observeComposerAttachments() { + viewModelScope.launch { + filePickerManager.attachedFiles.collect(::persistAttachedFiles) + } + } + init { + val restoredFiles = restoredAttachedFiles() + if (restoredFiles.isNotEmpty()) filePickerManager.restoreAttachedFiles(restoredFiles) + observeComposerAttachments() loadSession() loadMessages() modelAgentManager.loadAgents() @@ -191,6 +235,7 @@ class ChatViewModel constructor( } fun updateInput(text: String) { + persistInputText(text) _uiState.update { it.copy(inputText = text) } } @@ -316,7 +361,8 @@ class ChatViewModel constructor( val selectedAgent = modelAgentManager.selectedAgent.value val selectedModel = modelAgentManager.selectedModel.value val selectedVariant = modelAgentManager.currentReasoningEffort() - _uiState.update { it.copy(inputText = "", isSending = true) } + updateInput("") + _uiState.update { it.copy(isSending = true) } filePickerManager.clearAttachedFiles() viewModelScope.launch { @@ -338,10 +384,10 @@ class ChatViewModel constructor( _uiState.update { it.copy( isSending = false, - inputText = text, error = "Failed to send: ${result.message}" ) } + updateInput(text) filePickerManager.restoreAttachedFiles(attachedFiles) } } @@ -353,7 +399,7 @@ class ChatViewModel constructor( val commandName = commandText.substringBefore(" ").trim() if (commandName.isEmpty()) return val arguments = commandText.substringAfter(" ", "").trim() - _uiState.update { it.copy(inputText = "") } + updateInput("") executeCommand(commandName, arguments) } diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModelDraftPersistenceTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModelDraftPersistenceTest.kt new file mode 100644 index 00000000..73f83a97 --- /dev/null +++ b/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModelDraftPersistenceTest.kt @@ -0,0 +1,241 @@ +package dev.blazelight.p4oc.ui.screens.chat + +import androidx.lifecycle.SavedStateHandle +import dev.blazelight.p4oc.core.datastore.ChatSettings +import dev.blazelight.p4oc.core.datastore.NotificationSettings +import dev.blazelight.p4oc.core.datastore.SettingsDataStore +import dev.blazelight.p4oc.core.datastore.VisualSettings +import dev.blazelight.p4oc.core.haptic.HapticFeedback +import dev.blazelight.p4oc.core.log.AppLog +import dev.blazelight.p4oc.core.network.Connection +import dev.blazelight.p4oc.core.network.ConnectionManager +import dev.blazelight.p4oc.core.network.ConnectionState +import dev.blazelight.p4oc.core.network.OpenCodeApi +import dev.blazelight.p4oc.core.network.OpenCodeEventSource +import dev.blazelight.p4oc.core.network.ServerConfig +import dev.blazelight.p4oc.data.files.FileRepository +import dev.blazelight.p4oc.data.files.FileRepositoryFactory +import dev.blazelight.p4oc.data.remote.mapper.MessageMapper +import dev.blazelight.p4oc.data.server.ActiveServerApiProvider +import dev.blazelight.p4oc.data.session.SessionRepositoryImpl +import dev.blazelight.p4oc.data.workspace.WorkspaceClient +import dev.blazelight.p4oc.domain.server.ScopedEvent +import dev.blazelight.p4oc.domain.server.ServerGeneration +import dev.blazelight.p4oc.domain.server.ServerRef +import dev.blazelight.p4oc.domain.workspace.Workspace +import dev.blazelight.p4oc.ui.components.chat.SelectedFile +import dev.blazelight.p4oc.ui.navigation.Screen +import dev.blazelight.p4oc.ui.screens.files.upload.UploadCoordinator +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlinx.serialization.json.Json +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +@OptIn(ExperimentalCoroutinesApi::class) +class ChatViewModelDraftPersistenceTest { + + @get:Rule + val mainDispatcherRule = DraftPersistenceMainDispatcherRule() + + private lateinit var connectionManager: ConnectionManager + private lateinit var messageMapper: MessageMapper + private lateinit var settingsDataStore: SettingsDataStore + private lateinit var eventSource: OpenCodeEventSource + private lateinit var events: MutableSharedFlow + private lateinit var api: OpenCodeApi + private lateinit var workspaceClient: WorkspaceClient + private lateinit var sessionRepository: SessionRepositoryImpl + private lateinit var hapticFeedback: HapticFeedback + + @Before + fun setUp() { + mockkObject(AppLog) + every { AppLog.d(any(), any()) } returns Unit + every { AppLog.d(any(), any<() -> String>()) } returns Unit + every { AppLog.v(any(), any()) } returns Unit + every { AppLog.v(any(), any<() -> String>()) } returns Unit + every { AppLog.i(any(), any()) } returns Unit + every { AppLog.i(any(), any<() -> String>()) } returns Unit + every { AppLog.w(any(), any()) } returns Unit + every { AppLog.w(any(), any(), any()) } returns Unit + every { AppLog.e(any(), any()) } returns Unit + every { AppLog.e(any(), any(), any()) } returns Unit + + connectionManager = mockk() + messageMapper = MessageMapper(Json { ignoreUnknownKeys = true }) + settingsDataStore = mockk() + eventSource = mockk() + events = MutableSharedFlow(extraBufferCapacity = 32) + api = mockk(relaxed = true) + workspaceClient = WorkspaceClient( + workspace = Workspace( + server = ServerRef.fromEndpointKey("http://test.local"), + directory = "/test", + ), + generation = ServerGeneration(0L), + apiProvider = ActiveServerApiProvider { _, _ -> api }, + ) + every { connectionManager.connectionState } returns MutableStateFlow(ConnectionState.Disconnected) + every { connectionManager.getApi() } returns api + every { connectionManager.getEventSource() } returns eventSource + every { eventSource.events } returns MutableSharedFlow(extraBufferCapacity = 32) + every { connectionManager.connection } returns MutableStateFlow( + Connection( + config = ServerConfig.LOCAL_DEFAULT, + generation = ServerGeneration(0L), + api = api, + eventSource = eventSource, + ) + ) + every { connectionManager.scopedEvents } returns events + every { settingsDataStore.favoriteModels } returns flowOf(emptySet()) + every { settingsDataStore.recentModels } returns flowOf(emptyList()) + every { settingsDataStore.chatSettings } returns flowOf(ChatSettings()) + every { settingsDataStore.visualSettings } returns flowOf(VisualSettings()) + every { settingsDataStore.notificationSettings } returns flowOf(NotificationSettings()) + coEvery { settingsDataStore.getSelectedAgentForSession(any()) } returns null + coEvery { settingsDataStore.setSelectedAgentForSession(any(), any()) } returns Unit + + hapticFeedback = mockk(relaxed = true) + } + + @After + fun tearDown() { + unmockkObject(AppLog) + } + + @Test + fun updateInput_persistsDraftTextInSavedStateHandle() = runTest { + val savedStateHandle = SavedStateHandle(mapOf(Screen.Chat.ARG_SESSION_ID to "session-1")) + val vm = createViewModel(savedStateHandle) + + vm.updateInput("unsent draft") + + assertEquals("unsent draft", savedStateHandle.get("chat_draft_text")) + } + + @Test + fun createViewModel_restoresDraftTextFromSavedStateHandle() = runTest { + val savedStateHandle = SavedStateHandle( + mapOf( + Screen.Chat.ARG_SESSION_ID to "session-1", + "chat_draft_text" to "restored draft", + ) + ) + + val vm = createViewModel(savedStateHandle) + + assertEquals("restored draft", vm.uiState.value.inputText) + } + + @Test + fun createViewModel_doesNotShareDraftTextAcrossSavedStateHandles() = runTest { + val firstHandle = SavedStateHandle(mapOf(Screen.Chat.ARG_SESSION_ID to "session-1")) + val secondHandle = SavedStateHandle(mapOf(Screen.Chat.ARG_SESSION_ID to "session-2")) + val first = createViewModel(firstHandle) + + first.updateInput("session one draft") + val second = createViewModel(secondHandle) + + assertEquals("session one draft", first.uiState.value.inputText) + assertEquals("", second.uiState.value.inputText) + assertNull(secondHandle.get("chat_draft_text")) + } + + @Test + fun createViewModel_restoresAttachedFilesFromSavedStateHandle() = runTest { + val savedStateHandle = SavedStateHandle( + mapOf( + Screen.Chat.ARG_SESSION_ID to "session-1", + "chat_attached_files" to """[{"path":"src/Main.kt","name":"Main.kt","mimeType":"text/x-kotlin"}]""", + ) + ) + + val vm = createViewModel(savedStateHandle) + + assertEquals(listOf("src/Main.kt"), vm.filePickerManager.attachedFiles.value.map { it.path }) + } + + @Test + fun sendMessage_successClearsPersistedDraftAndAttachments() = runTest { + val savedStateHandle = SavedStateHandle(mapOf(Screen.Chat.ARG_SESSION_ID to "session-1")) + val vm = createViewModel(savedStateHandle) + coEvery { api.sendMessageAsync(any(), any(), any()) } returns Unit + vm.updateInput("hello") + vm.filePickerManager.restoreAttachedFiles( + listOf(SelectedFile(path = "src/Main.kt", name = "Main.kt", mimeType = "text/x-kotlin")) + ) + advanceUntilIdle() + assertEquals("hello", savedStateHandle.get("chat_draft_text")) + assertTrue(savedStateHandle.get("chat_attached_files")?.contains("src/Main.kt") == true) + + vm.sendMessage() + advanceUntilIdle() + + assertNull(savedStateHandle.get("chat_draft_text")) + assertNull(savedStateHandle.get("chat_attached_files")) + } + + private fun createViewModel( + savedStateHandle: SavedStateHandle = SavedStateHandle(mapOf(Screen.Chat.ARG_SESSION_ID to "session-1")) + ): ChatViewModel { + sessionRepository = SessionRepositoryImpl( + workspaceClient, + messageMapper, + dispatcher = StandardTestDispatcher(mainDispatcherRule.dispatcher.scheduler), + ) + val fileRepository = testFileRepository() + return ChatViewModel( + savedStateHandle = savedStateHandle, + workspaceClient = workspaceClient, + sessionRepository = sessionRepository, + uploadCoordinator = testUploadCoordinator(fileRepository), + connectionManager = connectionManager, + settingsDataStore = settingsDataStore, + hapticFeedback = hapticFeedback, + ) + } + + private fun testFileRepository(): FileRepository = FileRepositoryFactory.create(workspaceClient) + + private fun testUploadCoordinator(repo: FileRepository) = UploadCoordinator( + scope = CoroutineScope(Dispatchers.Main), + repositoryFactory = { repo }, + ) +} + +@OptIn(ExperimentalCoroutinesApi::class) +class DraftPersistenceMainDispatcherRule( + val dispatcher: TestDispatcher = StandardTestDispatcher() +) : TestWatcher() { + override fun starting(description: Description) { + Dispatchers.setMain(dispatcher) + } + + override fun finished(description: Description) { + Dispatchers.resetMain() + } +} diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModelTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModelTest.kt index 5c7da34c..10909d71 100644 --- a/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModelTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModelTest.kt @@ -406,6 +406,7 @@ class ChatViewModelTest { assertTrue(vm.uiState.value.error?.contains("boom") == true) } + @Test fun sendMessage_sendsBackendFileUrls_forWorkspaceAttachmentsWithSpecialCharacters() = runTest { val vm = createViewModel() @@ -581,7 +582,9 @@ class ChatViewModelTest { coVerify(exactly = 0) { api.unrevertSession(any(), any()) } } - private fun TestScope.createViewModel(): ChatViewModel { + private fun TestScope.createViewModel( + savedStateHandle: SavedStateHandle = SavedStateHandle(mapOf(Screen.Chat.ARG_SESSION_ID to "session-1")) + ): ChatViewModel { sessionRepository = SessionRepositoryImpl( workspaceClient, messageMapper, @@ -589,7 +592,7 @@ class ChatViewModelTest { ) val fileRepository = testFileRepository() val vm = ChatViewModel( - savedStateHandle = SavedStateHandle(mapOf(Screen.Chat.ARG_SESSION_ID to "session-1")), + savedStateHandle = savedStateHandle, workspaceClient = workspaceClient, sessionRepository = sessionRepository, uploadCoordinator = testUploadCoordinator(fileRepository), From e0cba1833cb70acb6ae933ac8f1787ce0103d6cb Mon Sep 17 00:00:00 2001 From: Jasmin Le Roux Date: Tue, 7 Jul 2026 13:20:10 +0200 Subject: [PATCH 06/22] Recover unavailable restored chat attachments --- .tickets/oa-77dh.md | 8 +- .tickets/oa-tmpy.md | 10 +- app/detekt-baseline.xml | 1 + .../p4oc/ui/components/chat/ChatInputBar.kt | 16 ++- .../ui/components/chat/FilePickerDialog.kt | 1 + .../p4oc/ui/screens/chat/ChatViewModel.kt | 67 +++++---- .../p4oc/ui/screens/chat/FilePickerManager.kt | 19 +++ app/src/main/res/values/strings.xml | 1 + .../chat/ChatViewModelDraftPersistenceTest.kt | 130 +++++++++++++++++- .../p4oc/ui/screens/chat/ChatViewModelTest.kt | 12 +- 10 files changed, 228 insertions(+), 37 deletions(-) diff --git a/.tickets/oa-77dh.md b/.tickets/oa-77dh.md index 22428185..1c2f2de3 100644 --- a/.tickets/oa-77dh.md +++ b/.tickets/oa-77dh.md @@ -1,8 +1,8 @@ --- id: oa-77dh -status: open +status: closed deps: [] -links: [oa-wxf2, oa-12ui, oa-cplr] +links: [oa-wxf2, oa-12ui, oa-cplr, oa-tmpy] created: 2026-07-05T18:07:13Z type: bug priority: 1 @@ -39,3 +39,7 @@ Run targeted ChatViewModel/ChatInputBar/FilePicker tests. Smoke test typing a dr **2026-07-06T19:23:04Z** Busy follow-up queue scope was resolved by architecture change rather than persistence: Android-local queuedMessages/queueMessage/sendQueuedMessageIfAny/QueuedMessagesStrip were removed, and busy Send now submits upstream immediately. The visible queued state is derived from upstream transcript messages. Remaining unsent draft/attachment lifecycle persistence is split to oa-cplr. + +**2026-07-07T11:19:29Z** + +All acceptance criteria are now satisfied by the split implementation: oa-cplr persisted/restored draft text and selected attachment references via the chat SavedStateHandle with session/tab-scoped isolation tests, while oa-tmpy added workspace-scoped restored-attachment validation, unavailable/removable attachment UI state, send blocking before raw failures, and focused missing/inaccessible attachment recovery tests. Android-local busy queued messages were already removed in favor of upstream-submitted follow-ups, with visible queued state derived from transcript messages. diff --git a/.tickets/oa-tmpy.md b/.tickets/oa-tmpy.md index d6f12759..7f95b906 100644 --- a/.tickets/oa-tmpy.md +++ b/.tickets/oa-tmpy.md @@ -1,8 +1,8 @@ --- id: oa-tmpy -status: open +status: closed deps: [] -links: [oa-cplr] +links: [oa-cplr, oa-77dh] created: 2026-07-06T19:43:41Z type: bug priority: 2 @@ -31,3 +31,9 @@ Acceptance Criteria: Verification: Restore a draft with one existing and one missing attachment; confirm the missing one is visibly recoverable/removable and the existing one still sends. + +## Notes + +**2026-07-07T11:18:02Z** + +Implemented restored attachment recovery with SelectedFile.available, workspace-scoped parent-directory validation in FilePickerManager, unavailable chip styling/label in ChatInputBar, and send blocking with a concise composer error while unavailable attachments remain. Added focused ChatViewModelDraftPersistenceTest coverage for available restored attachments, missing restored attachments, removal clearing the blocker, and validation failures. diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml index d26ad8dd..7f89f134 100644 --- a/app/detekt-baseline.xml +++ b/app/detekt-baseline.xml @@ -265,6 +265,7 @@ ImportOrdering:SlashCommandsPopup.kt$import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupPositionProvider import androidx.compose.ui.window.PopupProperties import dev.blazelight.p4oc.domain.model.Command import dev.blazelight.p4oc.domain.model.CommandSource import dev.blazelight.p4oc.ui.theme.LocalOpenCodeTheme import dev.blazelight.p4oc.ui.theme.Sizing import dev.blazelight.p4oc.ui.theme.Spacing Indentation:ChatScreen.kt$ InstanceOfCheckForException:DialogQueueManager.kt$DialogQueueManager$e is CancellationException + LargeClass:ChatViewModelTest.kt$ChatViewModelTest LargeClass:SessionRepositoryImpl.kt$SessionRepositoryImpl : SessionRepository LongMethod:AgentsConfigScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun AgentsConfigScreen( viewModel: AgentsConfigViewModel = koinViewModel(), onNavigateBack: () -> Unit ) LongMethod:AgentsConfigScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable private fun AgentCard( agent: AgentInfo, onClick: () -> Unit ) diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ChatInputBar.kt b/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ChatInputBar.kt index 7f8a5da4..211df171 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ChatInputBar.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ChatInputBar.kt @@ -177,12 +177,14 @@ fun ChatInputBar( horizontalArrangement = Arrangement.spacedBy(Spacing.md) ) { attachedFiles.forEach { file -> + val chipColor = if (file.available) theme.accent else theme.warning + val chipLabelColor = if (file.available) theme.text else theme.warning Surface( shape = RectangleShape, - color = theme.accent.copy(alpha = 0.1f), + color = chipColor.copy(alpha = 0.1f), modifier = Modifier .height(Sizing.buttonHeightSm) - .border(Sizing.strokeMd, theme.border, RectangleShape) + .border(Sizing.strokeMd, chipColor, RectangleShape) ) { Row( modifier = Modifier.padding(horizontal = Spacing.mdLg), @@ -193,11 +195,19 @@ fun ChatInputBar( file.name, style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace, - color = theme.text, + color = chipLabelColor, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.widthIn(max = Sizing.panelWidthMd) ) + if (!file.available) { + Text( + text = stringResource(R.string.attachment_unavailable), + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = theme.warning, + ) + } Text( text = "×", color = theme.textMuted, diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/FilePickerDialog.kt b/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/FilePickerDialog.kt index 8212a59e..321681ae 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/FilePickerDialog.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/FilePickerDialog.kt @@ -42,6 +42,7 @@ data class SelectedFile( val path: String, val name: String, val mimeType: String? = null, + val available: Boolean = true, ) @OptIn(ExperimentalMaterial3Api::class) diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModel.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModel.kt index 6640e154..f6f86e2e 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModel.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModel.kt @@ -145,6 +145,8 @@ class ChatViewModel constructor( const val TAG = "ChatViewModel" private const val KEY_DRAFT_TEXT = "chat_draft_text" private const val KEY_ATTACHED_FILES = "chat_attached_files" + private const val UNAVAILABLE_ATTACHMENTS_ERROR = + "Remove unavailable attachments before sending." /** * Built-in OpenCode commands that aren't returned by the /command API endpoint. @@ -214,6 +216,7 @@ class ChatViewModel constructor( init { val restoredFiles = restoredAttachedFiles() if (restoredFiles.isNotEmpty()) filePickerManager.restoreAttachedFiles(restoredFiles) + if (restoredFiles.isNotEmpty()) validateRestoredAttachments() observeComposerAttachments() loadSession() loadMessages() @@ -223,6 +226,12 @@ class ChatViewModel constructor( loadVcsInfo() } + private fun validateRestoredAttachments() { + viewModelScope.launch { + filePickerManager.validateAttachedFiles() + } + } + // --- Public API (delegating) --- fun markAsRead() { @@ -358,38 +367,48 @@ class ChatViewModel constructor( return } + _uiState.update { it.copy(isSending = true) } + viewModelScope.launch { + val validatedFiles = filePickerManager.validateAttachedFiles() + if (validatedFiles.any { !it.available }) { + _uiState.update { it.copy(error = UNAVAILABLE_ATTACHMENTS_ERROR, isSending = false) } + return@launch + } + + sendValidatedMessage(text, validatedFiles) + } + } + + private suspend fun sendValidatedMessage(text: String, attachedFiles: List) { val selectedAgent = modelAgentManager.selectedAgent.value val selectedModel = modelAgentManager.selectedModel.value val selectedVariant = modelAgentManager.currentReasoningEffort() updateInput("") - _uiState.update { it.copy(isSending = true) } filePickerManager.clearAttachedFiles() - viewModelScope.launch { - val parts = buildPartInputs(text, attachedFiles) - val request = SendMessageRequest( - parts = parts, - agent = selectedAgent, - model = selectedModel, - variant = selectedVariant - ) + val parts = buildPartInputs(text, attachedFiles) + val request = SendMessageRequest( + parts = parts, + agent = selectedAgent, + model = selectedModel, + variant = selectedVariant + ) - val result = sessionRepository.sendMessageAsync(SessionId(sessionId), request).await().toApiResult() - when (result) { - is ApiResult.Success -> { - _uiState.update { it.copy(isSending = false, isBusy = true) } - AppLog.d(TAG, "sendMessage: Async call succeeded, waiting for SSE events") - } - is ApiResult.Error -> { - _uiState.update { - it.copy( - isSending = false, - error = "Failed to send: ${result.message}" - ) - } - updateInput(text) - filePickerManager.restoreAttachedFiles(attachedFiles) + val result = sessionRepository.sendMessageAsync(SessionId(sessionId), request).await().toApiResult() + when (result) { + is ApiResult.Success -> { + _uiState.update { it.copy(isSending = false, isBusy = true) } + AppLog.d(TAG, "sendMessage: Async call succeeded, waiting for SSE events") + } + is ApiResult.Error -> { + _uiState.update { + it.copy( + isSending = false, + error = "Failed to send: ${result.message}" + ) } + updateInput(text) + filePickerManager.restoreAttachedFiles(attachedFiles) } } } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/FilePickerManager.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/FilePickerManager.kt index d70c176b..1527523f 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/FilePickerManager.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/FilePickerManager.kt @@ -121,6 +121,25 @@ class FilePickerManager( _attachedFiles.value = emptyList() } + suspend fun validateAttachedFiles(): List { + val current = _attachedFiles.value + if (current.isEmpty()) return current + + val validated = current.map { file -> + file.copy(available = isWorkspaceFileAvailable(file.path)) + } + _attachedFiles.value = validated + return validated + } + + private suspend fun isWorkspaceFileAvailable(path: String): Boolean { + val parentPath = path.substringBeforeLast('/', missingDelimiterValue = "") + return safeApiCall { workspaceClient.listFiles(parentPath) } + .getOrNull() + ?.any { it.path == path && it.type == "file" } + ?: false + } + fun uploadAndAttach(source: UploadSource, sourceIds: List) { val currentPath = _pickerCurrentPath.value.ifBlank { null } uploadCoordinator.upload( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e438be42..5d985864 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -742,4 +742,5 @@ uploading done failed + unavailable diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModelDraftPersistenceTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModelDraftPersistenceTest.kt index 73f83a97..c0067774 100644 --- a/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModelDraftPersistenceTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModelDraftPersistenceTest.kt @@ -15,6 +15,7 @@ import dev.blazelight.p4oc.core.network.OpenCodeEventSource import dev.blazelight.p4oc.core.network.ServerConfig import dev.blazelight.p4oc.data.files.FileRepository import dev.blazelight.p4oc.data.files.FileRepositoryFactory +import dev.blazelight.p4oc.data.remote.dto.FileNodeDto import dev.blazelight.p4oc.data.remote.mapper.MessageMapper import dev.blazelight.p4oc.data.server.ActiveServerApiProvider import dev.blazelight.p4oc.data.session.SessionRepositoryImpl @@ -27,6 +28,7 @@ import dev.blazelight.p4oc.ui.components.chat.SelectedFile import dev.blazelight.p4oc.ui.navigation.Screen import dev.blazelight.p4oc.ui.screens.files.upload.UploadCoordinator import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject @@ -39,13 +41,16 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import kotlinx.serialization.json.Json +import okhttp3.ResponseBody.Companion.toResponseBody import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before @@ -53,6 +58,8 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TestWatcher import org.junit.runner.Description +import retrofit2.HttpException +import retrofit2.Response @OptIn(ExperimentalCoroutinesApi::class) class ChatViewModelDraftPersistenceTest { @@ -179,11 +186,120 @@ class ChatViewModelDraftPersistenceTest { assertEquals(listOf("src/Main.kt"), vm.filePickerManager.attachedFiles.value.map { it.path }) } + @Test + fun createViewModel_restoresAvailableAttachmentAsSendable() = runTest { + coEvery { api.listFiles("src", "/test") } returns listOf( + FileNodeDto( + name = "Main.kt", + path = "src/Main.kt", + absolute = "/test/src/Main.kt", + type = "file", + ) + ) + coEvery { api.sendMessageAsync(any(), any(), any()) } returns Unit + val savedStateHandle = SavedStateHandle( + mapOf( + Screen.Chat.ARG_SESSION_ID to "session-1", + "chat_attached_files" to """[{"path":"src/Main.kt","name":"Main.kt","mimeType":"text/x-kotlin"}]""", + ) + ) + + val vm = createViewModel(savedStateHandle) + advanceUntilIdle() + + val restored = vm.filePickerManager.attachedFiles.value.single() + assertEquals("src/Main.kt", restored.path) + assertTrue(restored.available) + + vm.sendMessage() + advanceUntilIdle() + + coVerify(exactly = 1) { api.sendMessageAsync("session-1", any(), "/test") } + } + + @Test + fun createViewModel_marksRestoredMissingAttachmentUnavailableAndBlocksSend() = runTest { + coEvery { api.listFiles("src", "/test") } returns emptyList() + coEvery { api.sendMessageAsync(any(), any(), any()) } returns Unit + val savedStateHandle = SavedStateHandle( + mapOf( + Screen.Chat.ARG_SESSION_ID to "session-1", + "chat_attached_files" to missingAttachmentJson, + ) + ) + + val vm = createViewModel(savedStateHandle) + advanceUntilIdle() + + val restored = vm.filePickerManager.attachedFiles.value.single() + assertEquals("src/Missing.kt", restored.path) + assertFalse(restored.available) + + vm.updateInput("please read this") + vm.sendMessage() + advanceUntilIdle() + + coVerify(exactly = 0) { api.sendMessageAsync(any(), any(), any()) } + assertEquals("please read this", vm.uiState.value.inputText) + assertEquals(listOf("src/Missing.kt"), vm.filePickerManager.attachedFiles.value.map { it.path }) + } + + @Test + fun detachFile_removingUnavailableRestoredAttachmentClearsSendBlocker() = runTest { + coEvery { api.listFiles("src", "/test") } returns emptyList() + coEvery { api.sendMessageAsync(any(), any(), any()) } returns Unit + val savedStateHandle = SavedStateHandle( + mapOf( + Screen.Chat.ARG_SESSION_ID to "session-1", + "chat_attached_files" to missingAttachmentJson, + ) + ) + val vm = createViewModel(savedStateHandle) + advanceUntilIdle() + assertFalse(vm.filePickerManager.attachedFiles.value.single().available) + + vm.filePickerManager.detachFile("src/Missing.kt") + vm.updateInput("send without the missing file") + vm.sendMessage() + advanceUntilIdle() + + assertTrue(vm.filePickerManager.attachedFiles.value.isEmpty()) + coVerify(exactly = 1) { api.sendMessageAsync("session-1", any(), "/test") } + } + + @Test + fun createViewModel_marksRestoredAttachmentUnavailableWhenValidationFails() = runTest { + coEvery { api.listFiles("src", "/test") } throws HttpException( + Response.error(403, "forbidden".toResponseBody(null)) + ) + val savedStateHandle = SavedStateHandle( + mapOf( + Screen.Chat.ARG_SESSION_ID to "session-1", + "chat_attached_files" to privateAttachmentJson, + ) + ) + + val vm = createViewModel(savedStateHandle) + advanceUntilIdle() + + val restored = vm.filePickerManager.attachedFiles.value.single() + assertEquals("src/Private.kt", restored.path) + assertFalse(restored.available) + } + @Test fun sendMessage_successClearsPersistedDraftAndAttachments() = runTest { val savedStateHandle = SavedStateHandle(mapOf(Screen.Chat.ARG_SESSION_ID to "session-1")) val vm = createViewModel(savedStateHandle) coEvery { api.sendMessageAsync(any(), any(), any()) } returns Unit + coEvery { api.listFiles("src", "/test") } returns listOf( + FileNodeDto( + name = "Main.kt", + path = "src/Main.kt", + absolute = "/test/src/Main.kt", + type = "file", + ) + ) vm.updateInput("hello") vm.filePickerManager.restoreAttachedFiles( listOf(SelectedFile(path = "src/Main.kt", name = "Main.kt", mimeType = "text/x-kotlin")) @@ -199,8 +315,16 @@ class ChatViewModelDraftPersistenceTest { assertNull(savedStateHandle.get("chat_attached_files")) } - private fun createViewModel( - savedStateHandle: SavedStateHandle = SavedStateHandle(mapOf(Screen.Chat.ARG_SESSION_ID to "session-1")) + private val missingAttachmentJson = + """[{"path":"src/Missing.kt","name":"Missing.kt","mimeType":"text/x-kotlin"}]""" + + private val privateAttachmentJson = + """[{"path":"src/Private.kt","name":"Private.kt","mimeType":"text/x-kotlin"}]""" + + private fun TestScope.createViewModel( + savedStateHandle: SavedStateHandle = SavedStateHandle( + mapOf(Screen.Chat.ARG_SESSION_ID to "session-1") + ) ): ChatViewModel { sessionRepository = SessionRepositoryImpl( workspaceClient, @@ -216,7 +340,7 @@ class ChatViewModelDraftPersistenceTest { connectionManager = connectionManager, settingsDataStore = settingsDataStore, hapticFeedback = hapticFeedback, - ) + ).also { advanceUntilIdle() } } private fun testFileRepository(): FileRepository = FileRepositoryFactory.create(workspaceClient) diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModelTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModelTest.kt index 10909d71..748668c3 100644 --- a/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModelTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModelTest.kt @@ -16,6 +16,7 @@ import dev.blazelight.p4oc.core.network.ServerConfig import dev.blazelight.p4oc.data.files.FileRepository import dev.blazelight.p4oc.data.files.FileRepositoryFactory import dev.blazelight.p4oc.data.remote.dto.CommandDto +import dev.blazelight.p4oc.data.remote.dto.FileNodeDto import dev.blazelight.p4oc.data.remote.dto.MessageInfoDto import dev.blazelight.p4oc.data.remote.dto.MessageTimeDto import dev.blazelight.p4oc.data.remote.dto.MessageWrapperDto @@ -380,9 +381,6 @@ class ChatViewModelTest { vm.updateInput("hello") vm.sendMessage() - - assertEquals("", vm.uiState.value.inputText) - // isSending is set to true synchronously before the coroutine launches assertTrue(vm.uiState.value.isSending) advanceUntilIdle() @@ -412,6 +410,14 @@ class ChatViewModelTest { val vm = createViewModel() val request = slot() coEvery { api.sendMessageAsync(any(), capture(request), any()) } returns Unit + coEvery { api.listFiles("src/My File %/ümlaut/こんにちは", "/test") } returns listOf( + FileNodeDto( + name = "hash#query?.kt", + path = "src/My File %/ümlaut/こんにちは/hash#query?.kt", + absolute = "/test/src/My File %/ümlaut/こんにちは/hash#query?.kt", + type = "file", + ) + ) vm.filePickerManager.restoreAttachedFiles( listOf( SelectedFile( From 5dc6bb08dc19f361c5ea2a19462bfa4e174529d1 Mon Sep 17 00:00:00 2001 From: Jasmin Le Roux Date: Tue, 7 Jul 2026 18:04:33 +0200 Subject: [PATCH 07/22] Migrate tabs to WorkspaceKey identity --- app/detekt-baseline.xml | 10 +-- .../p4oc/core/datastore/SettingsDataStore.kt | 83 ++++++++++++++++++- .../ui/screens/files/FileExplorerScreen.kt | 12 ++- .../blazelight/p4oc/ui/tabs/MainTabScreen.kt | 44 +++++----- .../dev/blazelight/p4oc/ui/tabs/TabBar.kt | 29 ++++--- .../dev/blazelight/p4oc/ui/tabs/TabManager.kt | 27 +++--- .../dev/blazelight/p4oc/ui/tabs/TabNavHost.kt | 24 ++++-- .../dev/blazelight/p4oc/ui/tabs/TabState.kt | 15 ++-- .../p4oc/ui/tabs/TabManagerPersistenceTest.kt | 21 +++-- 9 files changed, 188 insertions(+), 77 deletions(-) diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml index 7f89f134..fe4c7bf4 100644 --- a/app/detekt-baseline.xml +++ b/app/detekt-baseline.xml @@ -14,7 +14,7 @@ CyclomaticComplexMethod:ExpandedWidgets.kt$@Composable fun TaskWidgetExpanded( tool: Part.Tool, onClick: (() -> Unit)?, showApprovalActions: Boolean, approvalRequestId: String = tool.callID, onToolApprove: (String) -> Unit, onToolDeny: (String) -> Unit, onOpenSubSession: ((String) -> Unit)?, modifier: Modifier = Modifier ) CyclomaticComplexMethod:FileExplorerScreen.kt$@Composable private fun getFileIcon(file: FileNode): Pair<ImageVector, Color> CyclomaticComplexMethod:FileExplorerScreen.kt$@Composable private fun getSymbolKind(kind: Int): Pair<String, Color> - CyclomaticComplexMethod:FileExplorerScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun FileExplorerScreen( viewModel: FilesViewModel, workspaceDirectory: String?, onFileClick: (String) -> Unit, onNavigateBack: () -> Unit, onSwitchWorkspace: () -> Unit = {}, ) + CyclomaticComplexMethod:FileExplorerScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun FileExplorerScreen( viewModel: FilesViewModel, workspaceKey: WorkspaceKey?, onFileClick: (String) -> Unit, onNavigateBack: () -> Unit, onSwitchWorkspace: () -> Unit = {}, ) CyclomaticComplexMethod:FilePathValidator.kt$FilePathValidator$private fun normalize(path: String, allowRoot: Boolean): Result<String> CyclomaticComplexMethod:FilePickerDialog.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun FilePickerDialog( files: List<FileNode>, currentPath: String, isLoading: Boolean, error: String?, selectedFiles: List<SelectedFile>, onNavigateTo: (String) -> Unit, onNavigateUp: () -> Unit, onFileSelected: (FileNode) -> Unit, onFileDeselected: (String) -> Unit, onUploadClick: () -> Unit, onConfirm: () -> Unit, onDismiss: () -> Unit ) CyclomaticComplexMethod:FileViewerScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun FileViewerScreen( path: String, viewModel: FilesViewModel, onNavigateBack: () -> Unit ) @@ -31,7 +31,7 @@ CyclomaticComplexMethod:SessionRepositoryImpl.kt$SessionRepositoryImpl$override fun acceptEvent(event: OpenCodeEvent) CyclomaticComplexMethod:StreamingMarkdown.kt$internal fun parseMarkdownBlocks(text: String): List<MarkdownBlock> CyclomaticComplexMethod:StreamingMarkdown.kt$private fun inlineMarkdown(text: String, colors: MarkdownRenderColors): AnnotatedString - CyclomaticComplexMethod:TabBar.kt$fun getTitleForRoute( route: String?, sessionTitle: String? = null, workspaceDirectory: String? = null, ): String + CyclomaticComplexMethod:TabBar.kt$fun getTitleForRoute( route: String?, sessionTitle: String? = null, workspaceKey: WorkspaceKey? = null, ): String CyclomaticComplexMethod:TabNavHost.kt$@Composable fun TabNavHost( navController: NavHostController, tabManager: TabManager, tabId: String, onDisconnect: () -> Unit, onNewFilesTab: () -> Unit = {}, onNewTerminalTab: () -> Unit = {}, onCloseTab: () -> Unit = {}, isActiveTab: Boolean = true, startRoute: String = Screen.Sessions.route, workspaceOwner: WorkspaceRepositoryOwner, onConnectionStateChanged: ((SessionConnectionState?) -> Unit)? = null, modifier: androidx.compose.ui.Modifier = androidx.compose.ui.Modifier ) CyclomaticComplexMethod:TermuxTerminalView.kt$KeyInterceptingContainer$private fun handleKeyDown(event: KeyEvent): Boolean CyclomaticComplexMethod:ToolCallWidget.kt$private fun getToolCompactDescription(tool: Part.Tool): String @@ -96,7 +96,7 @@ FunctionNaming:FileExplorerScreen.kt$@Composable private fun BreadcrumbNavigation( path: String, onNavigateTo: (String) -> Unit ) FunctionNaming:FileExplorerScreen.kt$@Composable private fun SymbolResultItem( symbol: Symbol, onClick: () -> Unit ) FunctionNaming:FileExplorerScreen.kt$@Composable private fun TuiGitStatusBadge(status: String) - FunctionNaming:FileExplorerScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun FileExplorerScreen( viewModel: FilesViewModel, workspaceDirectory: String?, onFileClick: (String) -> Unit, onNavigateBack: () -> Unit, onSwitchWorkspace: () -> Unit = {}, ) + FunctionNaming:FileExplorerScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun FileExplorerScreen( viewModel: FilesViewModel, workspaceKey: WorkspaceKey?, onFileClick: (String) -> Unit, onNavigateBack: () -> Unit, onSwitchWorkspace: () -> Unit = {}, ) FunctionNaming:FilePickerDialog.kt$@Composable private fun PickerBreadcrumb( path: String, onNavigateTo: (String) -> Unit ) FunctionNaming:FilePickerDialog.kt$@Composable private fun PickerFileItem( file: FileNode, isSelected: Boolean, onClick: () -> Unit ) FunctionNaming:FilePickerDialog.kt$@Composable private fun SelectedFilesChips( selectedFiles: List<SelectedFile>, onRemove: (String) -> Unit ) @@ -265,7 +265,6 @@ ImportOrdering:SlashCommandsPopup.kt$import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupPositionProvider import androidx.compose.ui.window.PopupProperties import dev.blazelight.p4oc.domain.model.Command import dev.blazelight.p4oc.domain.model.CommandSource import dev.blazelight.p4oc.ui.theme.LocalOpenCodeTheme import dev.blazelight.p4oc.ui.theme.Sizing import dev.blazelight.p4oc.ui.theme.Spacing Indentation:ChatScreen.kt$ InstanceOfCheckForException:DialogQueueManager.kt$DialogQueueManager$e is CancellationException - LargeClass:ChatViewModelTest.kt$ChatViewModelTest LargeClass:SessionRepositoryImpl.kt$SessionRepositoryImpl : SessionRepository LongMethod:AgentsConfigScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun AgentsConfigScreen( viewModel: AgentsConfigViewModel = koinViewModel(), onNavigateBack: () -> Unit ) LongMethod:AgentsConfigScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable private fun AgentCard( agent: AgentInfo, onClick: () -> Unit ) @@ -288,7 +287,7 @@ LongMethod:ExpandedWidgets.kt$@Composable fun ReadWidgetExpanded( tool: Part.Tool, onClick: (() -> Unit)?, modifier: Modifier = Modifier ) LongMethod:ExpandedWidgets.kt$@Composable fun TaskWidgetExpanded( tool: Part.Tool, onClick: (() -> Unit)?, showApprovalActions: Boolean, approvalRequestId: String = tool.callID, onToolApprove: (String) -> Unit, onToolDeny: (String) -> Unit, onOpenSubSession: ((String) -> Unit)?, modifier: Modifier = Modifier ) LongMethod:FallbackTheme.kt$fun createFallbackTheme(isDark: Boolean): OpenCodeTheme - LongMethod:FileExplorerScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun FileExplorerScreen( viewModel: FilesViewModel, workspaceDirectory: String?, onFileClick: (String) -> Unit, onNavigateBack: () -> Unit, onSwitchWorkspace: () -> Unit = {}, ) + LongMethod:FileExplorerScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun FileExplorerScreen( viewModel: FilesViewModel, workspaceKey: WorkspaceKey?, onFileClick: (String) -> Unit, onNavigateBack: () -> Unit, onSwitchWorkspace: () -> Unit = {}, ) LongMethod:FilePickerDialog.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun FilePickerDialog( files: List<FileNode>, currentPath: String, isLoading: Boolean, error: String?, selectedFiles: List<SelectedFile>, onNavigateTo: (String) -> Unit, onNavigateUp: () -> Unit, onFileSelected: (FileNode) -> Unit, onFileDeselected: (String) -> Unit, onUploadClick: () -> Unit, onConfirm: () -> Unit, onDismiss: () -> Unit ) LongMethod:FileViewerScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun FileViewerScreen( path: String, viewModel: FilesViewModel, onNavigateBack: () -> Unit ) LongMethod:InlineDiffViewer.kt$@Composable fun InlineDiffViewer( fileName: String, diffContent: String, additions: Int = 0, deletions: Int = 0, modifier: Modifier = Modifier ) @@ -590,7 +589,6 @@ MaxLineLength:ConnectionManager.kt$ConnectionManager$level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.NONE MaxLineLength:ConnectionManager.kt$ConnectionManager$level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.HEADERS else HttpLoggingInterceptor.Level.NONE MaxLineLength:FileExplorerScreen.kt$leadingIcon = { Icon(Icons.AutoMirrored.Filled.NoteAdd, contentDescription = null, tint = theme.textMuted) } - MaxLineLength:FileExplorerScreen.kt$val MaxLineLength:InlineDiffViewer.kt$currentDiffContent?.let { ParsedDiffParser.parse(it).allHunks().flatMap { hunk -> hunk.lines } } MaxLineLength:KoinModules.kt$"Workspace generation ${generation.value} does not match active generation ${activeGeneration?.value ?: "<none>"}" MaxLineLength:KoinModules.kt$"Workspace server ${serverRef.endpointKey} does not match active server ${activeServerRef.endpointKey}" diff --git a/app/src/main/java/dev/blazelight/p4oc/core/datastore/SettingsDataStore.kt b/app/src/main/java/dev/blazelight/p4oc/core/datastore/SettingsDataStore.kt index 50ac3f46..f687ed0f 100644 --- a/app/src/main/java/dev/blazelight/p4oc/core/datastore/SettingsDataStore.kt +++ b/app/src/main/java/dev/blazelight/p4oc/core/datastore/SettingsDataStore.kt @@ -8,6 +8,8 @@ import androidx.datastore.preferences.preferencesDataStore import dev.blazelight.p4oc.core.log.AppLog import dev.blazelight.p4oc.core.security.CredentialStore import dev.blazelight.p4oc.data.remote.dto.ModelInput +import dev.blazelight.p4oc.domain.server.WorkspaceKey +import dev.blazelight.p4oc.domain.session.SessionId import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -571,11 +573,30 @@ class SettingsDataStore constructor( } private fun parsePersistedTabState(stored: String): PersistedTabState? = try { - json.decodeFromString(stored) + migrateLegacyPersistedTabState(stored) ?: json.decodeFromString(stored) } catch (e: Exception) { AppLog.w(TAG, "Ignoring invalid persisted tab state: ${e.message}") null } + + private fun migrateLegacyPersistedTabState(stored: String): PersistedTabState? { + val legacy = json.decodeFromString(stored) + if (legacy.version >= PersistedTabState.CURRENT_VERSION) return null + return PersistedTabState( + version = PersistedTabState.CURRENT_VERSION, + serverEndpointKey = legacy.serverEndpointKey, + activeTabId = legacy.activeTabId, + tabs = legacy.tabs.map { tab -> + PersistedTab( + id = tab.id, + startRoute = tab.startRoute, + sessionId = tab.sessionId, + sessionTitle = tab.sessionTitle, + workspaceKey = tab.resolvedWorkspaceKey(), + ) + }, + ) + } } private fun removeDeadWorkspacePrefsMigration(): DataMigration = object : DataMigration { @@ -624,18 +645,72 @@ data class PersistedTabState( val tabs: List, ) { companion object { - const val CURRENT_VERSION = 1 + const val CURRENT_VERSION = 3 } } @Serializable -data class PersistedTab( +private data class LegacyPersistedTabState( + val version: Int = 1, + val serverEndpointKey: String, + val activeTabId: String?, + val tabs: List, +) + +@Serializable +private data class LegacyPersistedTab( val id: String, val startRoute: String, val sessionId: String? = null, val sessionTitle: String? = null, + val workspaceKey: PersistedWorkspaceKey? = null, val workspaceDirectory: String? = null, -) +) { + fun resolvedWorkspaceKey(): PersistedWorkspaceKey? = workspaceKey + ?: workspaceDirectory + ?.takeIf { it.isNotBlank() } + ?.let { PersistedWorkspaceKey(PersistedWorkspaceKey.Type.DIRECTORY, it) } + ?: if (startRoute == "sessions") PersistedWorkspaceKey(PersistedWorkspaceKey.Type.GLOBAL) else null +} + +@Serializable +data class PersistedTab( + val id: String, + val startRoute: String, + val sessionId: String? = null, + val sessionTitle: String? = null, + val workspaceKey: PersistedWorkspaceKey? = null, +) { + fun resolvedWorkspaceKey(): WorkspaceKey? = workspaceKey?.toWorkspaceKey() +} + +@Serializable +data class PersistedWorkspaceKey( + val type: Type, + val value: String? = null, +) { + enum class Type { GLOBAL, DIRECTORY, SESSION_SCOPED } + + fun toWorkspaceKey(): WorkspaceKey = when (type) { + Type.GLOBAL -> WorkspaceKey.Global + Type.DIRECTORY -> WorkspaceKey.Directory( + requireNotNull(value) { "Directory workspace key requires a value" } + ) + Type.SESSION_SCOPED -> WorkspaceKey.SessionScoped( + SessionId( + requireNotNull(value) { "Session-scoped workspace key requires a value" } + ) + ) + } + + companion object { + fun fromWorkspaceKey(workspaceKey: WorkspaceKey): PersistedWorkspaceKey = when (workspaceKey) { + WorkspaceKey.Global -> PersistedWorkspaceKey(Type.GLOBAL) + is WorkspaceKey.Directory -> PersistedWorkspaceKey(Type.DIRECTORY, workspaceKey.value) + is WorkspaceKey.SessionScoped -> PersistedWorkspaceKey(Type.SESSION_SCOPED, workspaceKey.sessionId.value) + } + } +} data class VisualSettings( val fontSize: Int = 14, diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/files/FileExplorerScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/files/FileExplorerScreen.kt index 9671b25a..f16b4423 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/files/FileExplorerScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/files/FileExplorerScreen.kt @@ -39,6 +39,7 @@ import dev.blazelight.p4oc.core.filetype.FileTypeCategory import dev.blazelight.p4oc.core.filetype.FileTypeClassifier import dev.blazelight.p4oc.domain.model.FileNode import dev.blazelight.p4oc.domain.model.Symbol +import dev.blazelight.p4oc.domain.server.WorkspaceKey import dev.blazelight.p4oc.ui.components.TuiAlertDialog import dev.blazelight.p4oc.ui.components.TuiButton import dev.blazelight.p4oc.ui.components.TuiConfirmDialog @@ -57,7 +58,7 @@ import dev.blazelight.p4oc.ui.theme.Spacing @Composable fun FileExplorerScreen( viewModel: FilesViewModel, - workspaceDirectory: String?, + workspaceKey: WorkspaceKey?, onFileClick: (String) -> Unit, onNavigateBack: () -> Unit, onSwitchWorkspace: () -> Unit = {}, @@ -167,8 +168,13 @@ fun FileExplorerScreen( color = theme.text, style = MaterialTheme.typography.titleMedium ) - val workspaceLabel = workspaceDirectory?.trimEnd('/')?.substringAfterLast('/')?.ifBlank { workspaceDirectory } - ?: stringResource(R.string.files_workspace_global) + val workspaceLabel = when (workspaceKey) { + WorkspaceKey.Global -> stringResource(R.string.files_workspace_global) + is WorkspaceKey.Directory -> workspaceKey.value.trimEnd('/').substringAfterLast('/') + .ifBlank { workspaceKey.value } + is WorkspaceKey.SessionScoped -> workspaceKey.sessionId.value + null -> stringResource(R.string.files_workspace_global) + } Text( text = workspaceLabel, maxLines = 1, diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/MainTabScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/MainTabScreen.kt index 0038003d..af4f43cb 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/MainTabScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/MainTabScreen.kt @@ -35,6 +35,7 @@ import dev.blazelight.p4oc.data.session.presence import dev.blazelight.p4oc.domain.model.SessionConnectionState import dev.blazelight.p4oc.domain.model.SessionStatus import dev.blazelight.p4oc.domain.server.ServerRef +import dev.blazelight.p4oc.domain.server.WorkspaceKey import dev.blazelight.p4oc.domain.session.SessionId import dev.blazelight.p4oc.domain.workspace.Workspace import dev.blazelight.p4oc.ui.components.TuiAlertDialog @@ -115,7 +116,7 @@ fun MainTabScreen( } if (!tabManager.hasTabs()) { - val initialTab = TabInstance(TabState()) + val initialTab = TabInstance(TabState(workspaceKey = WorkspaceKey.Global)) tabManager.registerTab(initialTab, focus = true) } } @@ -136,7 +137,7 @@ fun MainTabScreen( tabTitles[tab.id] = getTitleForRoute( route = tab.startRoute, sessionTitle = tab.sessionTitle, - workspaceDirectory = tab.workspaceDirectory, + workspaceKey = tab.workspaceKey, ) tabIcons[tab.id] = getIconForRoute(tab.startRoute) } @@ -173,9 +174,10 @@ fun MainTabScreen( } tabs.forEach { tab -> + val workspaceKey = tab.workspaceKey ?: WorkspaceKey.Global val workspace = Workspace( server = ServerRef.fromEndpoint(baseUrl), - directory = tab.workspaceDirectory, + directory = (workspaceKey as? WorkspaceKey.Directory)?.value, ) val currentOwner = workspaceOwners[tab.id] if (currentOwner == null || @@ -312,9 +314,8 @@ fun MainTabScreen( // Wrapped in a Box so the New-tab DropdownMenu can anchor to the top-end, // which visually aligns it near the + button inside TabBar. var newTabMenuExpanded by remember { mutableStateOf(false) } - // Snapshot the active tab's workspace so each menu item inherits it. - // AGENTS.md: workspaceDirectory must come from the active tab — no globals. - val activeWorkspaceDirectory = tabs.firstOrNull { it.id == activeTabId }?.workspaceDirectory + // Snapshot the active tab's workspace key so each menu item inherits it. + val activeWorkspaceKey = tabs.firstOrNull { it.id == activeTabId }?.workspaceKey ?: WorkspaceKey.Global Box(modifier = Modifier.fillMaxWidth()) { TabBar( tabs = tabs, @@ -349,7 +350,7 @@ fun MainTabScreen( newTabMenuExpanded = false tabManager.createTab( startRoute = Screen.Sessions.route, - workspaceDirectory = activeWorkspaceDirectory, + workspaceKey = activeWorkspaceKey, focus = true, ) }, @@ -393,7 +394,7 @@ fun MainTabScreen( val ptyId = result.data.id tabManager.createTab( startRoute = Screen.Terminal.createRoute(ptyId), - workspaceDirectory = activeWorkspaceDirectory, + workspaceKey = activeWorkspaceKey, focus = true, ) } @@ -458,7 +459,7 @@ fun MainTabScreen( tabTitles[tab.id] = getTitleForRoute( route = route, sessionTitle = tab.sessionTitle, - workspaceDirectory = tab.workspaceDirectory, + workspaceKey = tab.workspaceKey, ) tabIcons[tab.id] = getIconForRoute(route) } @@ -497,7 +498,7 @@ fun MainTabScreen( val ptyId = result.data.id tabManager.createTab( startRoute = Screen.Terminal.createRoute(ptyId), - workspaceDirectory = tab.workspaceDirectory, + workspaceKey = tab.workspaceKey ?: WorkspaceKey.Global, focus = true, ) } @@ -534,13 +535,13 @@ fun MainTabScreen( } if (showFilesTabPrompt) { - val openWorkspaceDirectories = tabs - .mapNotNull { it.workspaceDirectory } + val openWorkspaceKeys = tabs + .mapNotNull { it.workspaceKey } .distinct() - fun openFilesTab(directory: String?) { + fun openFilesTab(workspaceKey: WorkspaceKey) { tabManager.createTab( startRoute = Screen.Files.route, - workspaceDirectory = directory, + workspaceKey = workspaceKey, focus = true, ) showFilesTabPrompt = false @@ -560,19 +561,24 @@ fun MainTabScreen( title = "Global files", subtitle = "No project context", marker = "◆", - onClick = { openFilesTab(null) }, + onClick = { openFilesTab(WorkspaceKey.Global) }, ) - openWorkspaceDirectories.forEach { directory -> + openWorkspaceKeys.forEach { workspaceKey -> FilesWorkspaceOption( - title = directory.substringAfterLast('/').ifBlank { directory }, - subtitle = directory, + title = workspaceLabel(workspaceKey) ?: "Missing workspace", + subtitle = workspaceSubtitle(workspaceKey), marker = "◇", - onClick = { openFilesTab(directory) }, + onClick = { openFilesTab(workspaceKey) }, ) } } } } +private fun workspaceSubtitle(workspaceKey: WorkspaceKey): String = when (workspaceKey) { + is WorkspaceKey.Directory -> workspaceKey.value + WorkspaceKey.Global -> "No project context" + is WorkspaceKey.SessionScoped -> "Session-scoped workspace" +} @Composable private fun FilesWorkspaceOption( diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabBar.kt b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabBar.kt index 79014665..2feae136 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabBar.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabBar.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.style.TextOverflow import dev.blazelight.p4oc.domain.model.SessionConnectionState import dev.blazelight.p4oc.domain.model.SessionPresence +import dev.blazelight.p4oc.domain.server.WorkspaceKey import dev.blazelight.p4oc.ui.components.status.SessionStatusDot import dev.blazelight.p4oc.ui.theme.LocalOpenCodeTheme import dev.blazelight.p4oc.ui.theme.Sizing @@ -206,16 +207,16 @@ fun getIconForRoute(route: String?): ImageVector { fun getTitleForRoute( route: String?, sessionTitle: String? = null, - workspaceDirectory: String? = null, + workspaceKey: WorkspaceKey? = null, ): String { return when { route == null -> "Tab" - route == "sessions" -> withWorkspaceSuffix("Sessions", workspaceDirectory) - route.startsWith("sessions?") -> withWorkspaceSuffix("Sessions", workspaceDirectory) - route.startsWith("chat/") -> withWorkspaceSuffix(sessionTitle ?: "Chat", workspaceDirectory) - route == "files" -> workspaceBaseName(workspaceDirectory) ?: "Files" - route.startsWith("files/") -> workspaceBaseName(workspaceDirectory) ?: "File" - route.startsWith("terminal/") -> withWorkspaceSuffix(sessionTitle ?: "Terminal", workspaceDirectory) + route == "sessions" -> withWorkspaceSuffix("Sessions", workspaceKey) + route.startsWith("sessions?") -> withWorkspaceSuffix("Sessions", workspaceKey) + route.startsWith("chat/") -> withWorkspaceSuffix(sessionTitle ?: "Chat", workspaceKey) + route == "files" -> workspaceLabel(workspaceKey) ?: "Files" + route.startsWith("files/") -> workspaceLabel(workspaceKey) ?: "File" + route.startsWith("terminal/") -> withWorkspaceSuffix(sessionTitle ?: "Terminal", workspaceKey) route == "settings" -> "Settings" route.startsWith("settings/") -> "Settings" route == "projects" -> "Projects" @@ -223,12 +224,14 @@ fun getTitleForRoute( } } -private fun withWorkspaceSuffix(title: String, workspaceDirectory: String?): String { - val workspace = workspaceBaseName(workspaceDirectory) ?: return title +private fun withWorkspaceSuffix(title: String, workspaceKey: WorkspaceKey?): String { + val workspace = workspaceLabel(workspaceKey) ?: return title return "$title · $workspace" } -private fun workspaceBaseName(workspaceDirectory: String?): String? = workspaceDirectory - ?.trimEnd('/') - ?.substringAfterLast('/') - ?.ifBlank { workspaceDirectory } +fun workspaceLabel(workspaceKey: WorkspaceKey?): String? = when (workspaceKey) { + is WorkspaceKey.Directory -> workspaceKey.value.trimEnd('/').substringAfterLast('/').ifBlank { workspaceKey.value } + WorkspaceKey.Global -> "Global" + is WorkspaceKey.SessionScoped -> "Session" + null -> null +} diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabManager.kt b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabManager.kt index 9aa75592..82e13a9c 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabManager.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabManager.kt @@ -2,7 +2,9 @@ package dev.blazelight.p4oc.ui.tabs import dev.blazelight.p4oc.core.datastore.PersistedTab import dev.blazelight.p4oc.core.datastore.PersistedTabState +import dev.blazelight.p4oc.core.datastore.PersistedWorkspaceKey import dev.blazelight.p4oc.domain.server.ServerRef +import dev.blazelight.p4oc.domain.server.WorkspaceKey import dev.blazelight.p4oc.ui.navigation.Screen import dev.blazelight.p4oc.ui.navigation.TabChatRouteCodec import kotlinx.coroutines.flow.MutableStateFlow @@ -50,14 +52,17 @@ class TabManager { */ fun createTab( startRoute: String = "sessions", - workspaceDirectory: String? = null, + workspaceKey: WorkspaceKey, focus: Boolean = true - ): TabInstance { - val tab = TabInstance( - TabState(workspaceDirectory = workspaceDirectory?.takeIf { it.isNotBlank() }), + ): TabInstance = addTab( + tab = TabInstance( + TabState(workspaceKey = workspaceKey), startRoute = startRoute, - ) + ), + focus = focus, + ) + private fun addTab(tab: TabInstance, focus: Boolean): TabInstance { _tabs.update { currentTabs -> val newTabs = currentTabs + tab @@ -92,7 +97,7 @@ class TabManager { if (currentTabs.size == 1) { // Last tab - create a fresh replacement - val newTab = TabInstance(TabState()) + val newTab = TabInstance(TabState(workspaceKey = WorkspaceKey.Global)) _tabs.value = listOf(newTab) _activeTabId.value = newTab.id return @@ -150,12 +155,12 @@ class TabManager { } /** - * Switch only one tab to a different workspace directory. + * Switch only one tab to a different workspace key. */ - fun updateTabWorkspace(tabId: String, directory: String?) { + fun updateTabWorkspace(tabId: String, workspaceKey: WorkspaceKey) { _tabs.update { tabs -> tabs.map { tab -> - if (tab.id == tabId) tab.withWorkspaceDirectory(directory) else tab + if (tab.id == tabId) tab.withWorkspaceKey(workspaceKey) else tab } } } @@ -172,7 +177,7 @@ class TabManager { startRoute = persistableStartRoute(tab), sessionId = tab.sessionId, sessionTitle = tab.sessionTitle, - workspaceDirectory = tab.workspaceDirectory, + workspaceKey = tab.workspaceKey?.let(PersistedWorkspaceKey::fromWorkspaceKey), ) }, ) @@ -198,7 +203,7 @@ class TabManager { id = persisted.id, sessionId = persisted.sessionId, sessionTitle = persisted.sessionTitle, - workspaceDirectory = persisted.workspaceDirectory, + workspaceKey = persisted.resolvedWorkspaceKey(), ), startRoute = route, ) diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabNavHost.kt b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabNavHost.kt index aa33472a..7b60a106 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabNavHost.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabNavHost.kt @@ -24,6 +24,7 @@ import androidx.navigation.navArgument import dev.blazelight.p4oc.core.datastore.SettingsDataStore import dev.blazelight.p4oc.core.datastore.VisualSettings import dev.blazelight.p4oc.domain.model.SessionConnectionState +import dev.blazelight.p4oc.domain.server.WorkspaceKey import dev.blazelight.p4oc.ui.navigation.Screen import dev.blazelight.p4oc.ui.screens.chat.ChatScreen import dev.blazelight.p4oc.ui.screens.diff.DiffViewerScreen @@ -178,7 +179,7 @@ fun TabNavHost( val chatRoute = Screen.Chat.createRoute(sessionId) if (directory != workspaceOwner.workspace.directory) { pendingRoute = chatRoute - tabManager.updateTabWorkspace(tabId, directory) + tabManager.updateTabWorkspace(tabId, directory.toWorkspaceKey()) } else { navController.navigate(chatRoute) } @@ -188,7 +189,7 @@ fun TabNavHost( val chatRoute = Screen.Chat.createRoute(sessionId) if (directory != workspaceOwner.workspace.directory) { pendingRoute = chatRoute - tabManager.updateTabWorkspace(tabId, directory) + tabManager.updateTabWorkspace(tabId, directory.toWorkspaceKey()) } else { navController.navigate(chatRoute) } @@ -208,7 +209,7 @@ fun TabNavHost( onCreateSessionInWorkspace = { title, directory -> pendingSessionCreate = PendingSessionCreate(title, directory) if (directory != workspaceOwner.workspace.directory) { - tabManager.updateTabWorkspace(tabId, directory) + tabManager.updateTabWorkspace(tabId, directory.toWorkspaceKey()) } }, autoCreateSession = pendingSessionCreate != null && @@ -251,7 +252,7 @@ fun TabNavHost( val chatRoute = Screen.Chat.createRoute(sessionId) if (directory != workspaceOwner.workspace.directory) { pendingRoute = chatRoute - tabManager.updateTabWorkspace(tabId, directory) + tabManager.updateTabWorkspace(tabId, directory.toWorkspaceKey()) } else { navController.navigate(chatRoute) } @@ -261,7 +262,7 @@ fun TabNavHost( val chatRoute = Screen.Chat.createRoute(sessionId) if (directory != workspaceOwner.workspace.directory) { pendingRoute = chatRoute - tabManager.updateTabWorkspace(tabId, directory) + tabManager.updateTabWorkspace(tabId, directory.toWorkspaceKey()) } else { navController.navigate(chatRoute) } @@ -282,7 +283,7 @@ fun TabNavHost( pendingSessionCreate = PendingSessionCreate(title, directory) if (directory != workspaceOwner.workspace.directory) { pendingRoute = Screen.SessionsFiltered.createRoute(projectId) - tabManager.updateTabWorkspace(tabId, directory) + tabManager.updateTabWorkspace(tabId, directory.toWorkspaceKey()) } }, autoCreateSession = pendingSessionCreate != null && @@ -342,7 +343,7 @@ fun TabNavHost( // Open in a new tab (default), inheriting the source tab's workspace tabManager.createTab( startRoute = Screen.Chat.createRoute(subSessionId), - workspaceDirectory = workspaceOwner.workspace.directory, + workspaceKey = workspaceOwner.workspace.key, focus = true, ) } else { @@ -370,7 +371,7 @@ fun TabNavHost( val filteredRoute = Screen.SessionsFiltered.createRoute(projectId) if (worktree != workspaceOwner.workspace.directory) { pendingRoute = filteredRoute - tabManager.updateTabWorkspace(tabId, worktree) + tabManager.updateTabWorkspace(tabId, worktree.toWorkspaceKey()) } else { navController.navigate(filteredRoute) } @@ -409,7 +410,7 @@ fun TabNavHost( workspaceViewModel = workspaceViewModel, keySuffix = "files", ), - workspaceDirectory = workspaceOwner.workspace.directory, + workspaceKey = workspaceOwner.workspace.key, onFileClick = { path -> navController.navigate(Screen.FileViewer.createRoute(path)) }, @@ -653,3 +654,8 @@ private fun filesViewModelForRoute( key = "${workspaceViewModel.tabId}:${workspaceViewModel.workspace.key}:$keySuffix", parameters = { parametersOf(workspaceViewModel.fileRepository, workspaceViewModel.uploadCoordinator) }, ) + +private fun String?.toWorkspaceKey(): WorkspaceKey = this + ?.takeIf { it.isNotBlank() } + ?.let(WorkspaceKey::Directory) + ?: WorkspaceKey.Global diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabState.kt b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabState.kt index fecc55c4..efc6a3c3 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabState.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabState.kt @@ -1,6 +1,7 @@ package dev.blazelight.p4oc.ui.tabs import dev.blazelight.p4oc.domain.model.SessionConnectionState +import dev.blazelight.p4oc.domain.server.WorkspaceKey import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -30,8 +31,8 @@ data class TabState( */ val sessionTitle: String? = null, - /** Workspace directory owned by this tab. Null means server-global workspace. */ - val workspaceDirectory: String? = null, + /** Workspace key owned by this tab. Null is reserved for legacy recovery. */ + val workspaceKey: WorkspaceKey? = null, /** Incremented when workspace changes so navigation graph scoped ViewModels are recreated. */ val workspaceRevision: Int = 0, @@ -49,7 +50,8 @@ class TabInstance( val id: String get() = state.id val sessionId: String? get() = state.sessionId val sessionTitle: String? get() = state.sessionTitle - val workspaceDirectory: String? get() = state.workspaceDirectory + val workspaceKey: WorkspaceKey? get() = state.workspaceKey + val workspaceDirectory: String? get() = (state.workspaceKey as? WorkspaceKey.Directory)?.value val workspaceRevision: Int get() = state.workspaceRevision /** Connection state for this tab (only relevant for chat tabs) */ @@ -71,12 +73,11 @@ class TabInstance( return withState(state.copy(sessionId = sessionId, sessionTitle = sessionTitle)) } - fun withWorkspaceDirectory(directory: String?): TabInstance { - val normalized = directory?.takeIf { it.isNotBlank() } - if (normalized == state.workspaceDirectory) return this + fun withWorkspaceKey(workspaceKey: WorkspaceKey?): TabInstance { + if (workspaceKey == state.workspaceKey) return this return withState( state.copy( - workspaceDirectory = normalized, + workspaceKey = workspaceKey, workspaceRevision = state.workspaceRevision + 1, sessionId = null, sessionTitle = null, diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/tabs/TabManagerPersistenceTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/tabs/TabManagerPersistenceTest.kt index 04a0f759..8f6f6c41 100644 --- a/app/src/test/java/dev/blazelight/p4oc/ui/tabs/TabManagerPersistenceTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/ui/tabs/TabManagerPersistenceTest.kt @@ -2,7 +2,9 @@ package dev.blazelight.p4oc.ui.tabs import dev.blazelight.p4oc.core.datastore.PersistedTab import dev.blazelight.p4oc.core.datastore.PersistedTabState +import dev.blazelight.p4oc.core.datastore.PersistedWorkspaceKey import dev.blazelight.p4oc.domain.server.ServerRef +import dev.blazelight.p4oc.domain.server.WorkspaceKey import dev.blazelight.p4oc.ui.navigation.Screen import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -15,8 +17,12 @@ class TabManagerPersistenceTest { @Test fun `saveState writes versioned tabs with server endpoint key`() { val manager = TabManager() - val tab = manager.createTab(startRoute = Screen.Sessions.route, focus = true) - manager.updateTabWorkspace(tab.id, "/repo/a") + val tab = manager.createTab( + startRoute = Screen.Sessions.route, + workspaceKey = WorkspaceKey.Global, + focus = true, + ) + manager.updateTabWorkspace(tab.id, WorkspaceKey.Directory("/repo/a")) manager.updateTabSession(tab.id, "s1", "Title") val saved = manager.saveState(server)!! @@ -25,7 +31,8 @@ class TabManagerPersistenceTest { assertEquals(server.endpointKey, saved.serverEndpointKey) assertEquals(tab.id, saved.activeTabId) assertEquals("s1", saved.tabs.single().sessionId) - assertEquals("/repo/a", saved.tabs.single().workspaceDirectory) + assertEquals(PersistedWorkspaceKey.Type.DIRECTORY, saved.tabs.single().workspaceKey?.type) + assertEquals("/repo/a", saved.tabs.single().workspaceKey?.value) } @Test @@ -40,7 +47,7 @@ class TabManagerPersistenceTest { startRoute = Screen.Sessions.route, sessionId = "session with space", sessionTitle = "Chat", - workspaceDirectory = "/repo/a b", + workspaceKey = PersistedWorkspaceKey(PersistedWorkspaceKey.Type.DIRECTORY, "/repo/a b"), ), ), ) @@ -88,7 +95,11 @@ class TabManagerPersistenceTest { @Test fun `terminal routes are not persisted as resurrectable tabs`() { val manager = TabManager() - manager.createTab(startRoute = Screen.Terminal.createRoute("pty-1"), focus = true) + manager.createTab( + startRoute = Screen.Terminal.createRoute("pty-1"), + workspaceKey = WorkspaceKey.Global, + focus = true, + ) val saved = manager.saveState(server)!! From c4c7a7cc1aff0aaf801cf5917ee71c982428d888 Mon Sep 17 00:00:00 2001 From: Jasmin Le Roux Date: Tue, 7 Jul 2026 18:06:42 +0200 Subject: [PATCH 08/22] Spike global plus workspace policy --- .../p4oc/data/remote/dto/PtyDtos.kt | 6 ++--- .../blazelight/p4oc/ui/tabs/MainTabScreen.kt | 26 ++++++++++++++----- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/dev/blazelight/p4oc/data/remote/dto/PtyDtos.kt b/app/src/main/java/dev/blazelight/p4oc/data/remote/dto/PtyDtos.kt index 029cc6f1..3d7ec871 100644 --- a/app/src/main/java/dev/blazelight/p4oc/data/remote/dto/PtyDtos.kt +++ b/app/src/main/java/dev/blazelight/p4oc/data/remote/dto/PtyDtos.kt @@ -19,10 +19,10 @@ data class PtyDto( @Serializable data class CreatePtyRequest( - val command: String = "/bin/bash", + val command: String? = null, val args: List = emptyList(), - val cwd: String = ".", - val title: String = "Terminal", + val cwd: String? = null, + val title: String? = null, val env: Map = emptyMap() ) diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/MainTabScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/MainTabScreen.kt index af4f43cb..b428a7b3 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/MainTabScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/MainTabScreen.kt @@ -314,8 +314,8 @@ fun MainTabScreen( // Wrapped in a Box so the New-tab DropdownMenu can anchor to the top-end, // which visually aligns it near the + button inside TabBar. var newTabMenuExpanded by remember { mutableStateOf(false) } - // Snapshot the active tab's workspace key so each menu item inherits it. - val activeWorkspaceKey = tabs.firstOrNull { it.id == activeTabId }?.workspaceKey ?: WorkspaceKey.Global + // Top-level plus actions are server/global by default; contextual tab actions inherit below. + val globalWorkspaceKey = WorkspaceKey.Global Box(modifier = Modifier.fillMaxWidth()) { TabBar( tabs = tabs, @@ -350,7 +350,7 @@ fun MainTabScreen( newTabMenuExpanded = false tabManager.createTab( startRoute = Screen.Sessions.route, - workspaceKey = activeWorkspaceKey, + workspaceKey = globalWorkspaceKey, focus = true, ) }, @@ -394,7 +394,7 @@ fun MainTabScreen( val ptyId = result.data.id tabManager.createTab( startRoute = Screen.Terminal.createRoute(ptyId), - workspaceKey = activeWorkspaceKey, + workspaceKey = globalWorkspaceKey, focus = true, ) } @@ -492,13 +492,21 @@ fun MainTabScreen( snackbarHostState.showSnackbar("Not connected to server") return@launch } - val result = safeApiCall { api.createPtySession(CreatePtyRequest()) } + val workspaceKey = tab.workspaceKey ?: WorkspaceKey.Global + val result = safeApiCall { + api.createPtySession( + CreatePtyRequest( + cwd = (workspaceKey as? WorkspaceKey.Directory)?.value, + title = terminalTitle(workspaceKey), + ) + ) + } when (result) { is ApiResult.Success -> { val ptyId = result.data.id tabManager.createTab( startRoute = Screen.Terminal.createRoute(ptyId), - workspaceKey = tab.workspaceKey ?: WorkspaceKey.Global, + workspaceKey = workspaceKey, focus = true, ) } @@ -574,6 +582,12 @@ fun MainTabScreen( } } } +private fun terminalTitle(workspaceKey: WorkspaceKey): String? = when (workspaceKey) { + is WorkspaceKey.Directory -> workspaceKey.value.trimEnd('/').substringAfterLast('/').ifBlank { "Terminal" } + WorkspaceKey.Global -> null + is WorkspaceKey.SessionScoped -> workspaceKey.sessionId.value +} + private fun workspaceSubtitle(workspaceKey: WorkspaceKey): String = when (workspaceKey) { is WorkspaceKey.Directory -> workspaceKey.value WorkspaceKey.Global -> "No project context" From bcce909596759176b97270f4aae8304b0469ef9d Mon Sep 17 00:00:00 2001 From: Jasmin Le Roux Date: Tue, 7 Jul 2026 21:45:01 +0200 Subject: [PATCH 09/22] Drop ambiguous restored workspace tabs --- .gitignore | 4 + .tickets/oa-0e8o.md | 19 +++ .tickets/oa-0f4m.md | 26 ++++ .tickets/oa-0lb9.md | 40 ++++++ .tickets/oa-0mel.md | 52 +++++++ .tickets/oa-0p94.md | 41 ++++++ .tickets/oa-0qgz.md | 39 ++++++ .tickets/oa-0r8n.md | 32 +++++ .tickets/oa-12ui.md | 36 +++++ .tickets/oa-16lj.md | 40 ++++++ .tickets/oa-19hl.md | 38 ++++++ .tickets/oa-1bz6.md | 40 ++++++ .tickets/oa-1csc.md | 41 ++++++ .tickets/oa-1n6h.md | 60 ++++++++ .tickets/oa-1ond.md | 39 ++++++ .tickets/oa-339m.md | 40 ++++++ .tickets/oa-3l1w.md | 34 +++++ .tickets/oa-3rk7.md | 16 +++ .tickets/oa-3vga.md | 40 ++++++ .tickets/oa-3yk2.md | 86 ++++++++++++ .tickets/oa-3zxu.md | 38 ++++++ .tickets/oa-4olr.md | 33 +++++ .tickets/oa-5c1e.md | 41 ++++++ .tickets/oa-62nd.md | 42 ++++++ .tickets/oa-6d53.md | 20 +++ .tickets/oa-6s5v.md | 39 ++++++ .tickets/oa-6zta.md | 44 ++++++ .tickets/oa-764s.md | 57 ++++++++ .tickets/oa-7uuf.md | 39 ++++++ .tickets/oa-7wc7.md | 26 ++++ .tickets/oa-7ysx.md | 26 ++++ .tickets/oa-82uy.md | 44 ++++++ .tickets/oa-8dfl.md | 39 ++++++ .tickets/oa-8qyw.md | 32 +++++ .tickets/oa-9ev3.md | 68 ++++++++++ .tickets/oa-9o9t.md | 39 ++++++ .tickets/oa-a6l7.md | 51 +++++++ .tickets/oa-acdb.md | 39 ++++++ .tickets/oa-aihi.md | 44 ++++++ .tickets/oa-ap7c.md | 15 ++ .tickets/oa-as4e.md | 41 ++++++ .tickets/oa-blgp.md | 26 ++++ .tickets/oa-casy.md | 59 ++++++++ .tickets/oa-cemz.md | 26 ++++ .tickets/oa-cq7n.md | 33 +++++ .tickets/oa-cxjk.md | 28 ++++ .tickets/oa-d55a.md | 43 ++++++ .tickets/oa-de13.md | 15 ++ .tickets/oa-dpgg.md | 33 +++++ .tickets/oa-dpm0.md | 42 ++++++ .tickets/oa-dygk.md | 24 ++++ .tickets/oa-e07q.md | 26 ++++ .tickets/oa-e6g3.md | 92 +++++++++++++ .tickets/oa-e6gu.md | 24 ++++ .tickets/oa-erzs.md | 2 +- .tickets/oa-es45.md | 59 ++++++++ .tickets/oa-eywk.md | 15 ++ .tickets/oa-f0p5.md | 42 ++++++ .tickets/oa-ff10.md | 39 ++++++ .tickets/oa-ft0e.md | 20 +++ .tickets/oa-fuc8.md | 26 ++++ .tickets/oa-fyag.md | 15 ++ .tickets/oa-g8t3.md | 41 ++++++ .tickets/oa-gt0g.md | 25 ++++ .tickets/oa-gtw8.md | 35 +++++ .tickets/oa-hvd4.md | 16 +++ .tickets/oa-hysu.md | 39 ++++++ .tickets/oa-i56p.md | 39 ++++++ .tickets/oa-i8xb.md | 15 ++ .tickets/oa-it8h.md | 15 ++ .tickets/oa-ivm4.md | 39 ++++++ .tickets/oa-ivwp.md | 35 +++++ .tickets/oa-ja73.md | 20 +++ .tickets/oa-jbvh.md | 24 ++++ .tickets/oa-jm7x.md | 20 +++ .tickets/oa-jvyb.md | 34 +++++ .tickets/oa-k5fx.md | 36 +++++ .tickets/oa-ka08.md | 41 ++++++ .tickets/oa-lmh0.md | 33 +++++ .tickets/oa-m5a8.md | 33 +++++ .tickets/oa-n1fs.md | 35 +++++ .tickets/oa-n86n.md | 33 +++++ .tickets/oa-na7x.md | 39 ++++++ .tickets/oa-nam8.md | 43 ++++++ .tickets/oa-nhg0.md | 46 +++++++ .tickets/oa-nwha.md | 33 +++++ .tickets/oa-of3b.md | 34 +++++ .tickets/oa-p7ei.md | 127 +++++++++++++++++ .tickets/oa-p829.md | 40 ++++++ .tickets/oa-p98b.md | 58 ++++++++ .tickets/oa-pecx.md | 15 ++ .tickets/oa-plno.md | 35 +++++ .tickets/oa-poi7.md | 42 ++++++ .tickets/oa-prjv.md | 35 +++++ .tickets/oa-puhk.md | 40 ++++++ .tickets/oa-q2x1.md | 24 ++++ .tickets/oa-qr52.md | 20 +++ .tickets/oa-qu7u.md | 26 ++++ .tickets/oa-qv8d.md | 33 +++++ .tickets/oa-qy0f.md | 103 ++++++++++++++ .tickets/oa-r0sq.md | 40 ++++++ .tickets/oa-r8yn.md | 20 +++ .tickets/oa-rde5.md | 40 ++++++ .tickets/oa-rnf3.md | 24 ++++ .tickets/oa-ryve.md | 26 ++++ .tickets/oa-s5jj.md | 40 ++++++ .tickets/oa-ssm2.md | 25 ++++ .tickets/oa-t3tb.md | 34 +++++ .tickets/oa-t4t2.md | 58 ++++++++ .tickets/oa-tk1k.md | 33 +++++ .tickets/oa-tqsm.md | 33 +++++ .tickets/oa-tzta.md | 35 +++++ .tickets/oa-ua2q.md | 52 +++++++ .tickets/oa-udp9.md | 39 ++++++ .tickets/oa-uiiw.md | 39 ++++++ .tickets/oa-v3js.md | 57 ++++++++ .tickets/oa-vf6h.md | 35 +++++ .tickets/oa-vg20.md | 26 ++++ .tickets/oa-vnoe.md | 78 +++++++++++ .tickets/oa-vvep.md | 26 ++++ .tickets/oa-wfgc.md | 42 ++++++ .tickets/oa-wmvc.md | 82 +++++++++++ .tickets/oa-wqyr.md | 26 ++++ .tickets/oa-ww0m.md | 26 ++++ .tickets/oa-wxf2.md | 128 ++++++++++++++++++ .tickets/oa-x9pe.md | 20 +++ .tickets/oa-xi7k.md | 40 ++++++ .tickets/oa-xr6w.md | 15 ++ .tickets/oa-yjii.md | 15 ++ .tickets/oa-yzwd.md | 39 ++++++ .tickets/oa-zbei.md | 39 ++++++ .../p4oc/core/datastore/SettingsDataStore.kt | 22 +-- .../blazelight/p4oc/ui/tabs/MainTabScreen.kt | 10 +- .../dev/blazelight/p4oc/ui/tabs/TabManager.kt | 3 +- .../p4oc/ui/tabs/TabManagerPersistenceTest.kt | 56 ++++++++ 135 files changed, 4979 insertions(+), 14 deletions(-) create mode 100644 .tickets/oa-0e8o.md create mode 100644 .tickets/oa-0f4m.md create mode 100644 .tickets/oa-0lb9.md create mode 100644 .tickets/oa-0mel.md create mode 100644 .tickets/oa-0p94.md create mode 100644 .tickets/oa-0qgz.md create mode 100644 .tickets/oa-0r8n.md create mode 100644 .tickets/oa-12ui.md create mode 100644 .tickets/oa-16lj.md create mode 100644 .tickets/oa-19hl.md create mode 100644 .tickets/oa-1bz6.md create mode 100644 .tickets/oa-1csc.md create mode 100644 .tickets/oa-1n6h.md create mode 100644 .tickets/oa-1ond.md create mode 100644 .tickets/oa-339m.md create mode 100644 .tickets/oa-3l1w.md create mode 100644 .tickets/oa-3rk7.md create mode 100644 .tickets/oa-3vga.md create mode 100644 .tickets/oa-3yk2.md create mode 100644 .tickets/oa-3zxu.md create mode 100644 .tickets/oa-4olr.md create mode 100644 .tickets/oa-5c1e.md create mode 100644 .tickets/oa-62nd.md create mode 100644 .tickets/oa-6d53.md create mode 100644 .tickets/oa-6s5v.md create mode 100644 .tickets/oa-6zta.md create mode 100644 .tickets/oa-764s.md create mode 100644 .tickets/oa-7uuf.md create mode 100644 .tickets/oa-7wc7.md create mode 100644 .tickets/oa-7ysx.md create mode 100644 .tickets/oa-82uy.md create mode 100644 .tickets/oa-8dfl.md create mode 100644 .tickets/oa-8qyw.md create mode 100644 .tickets/oa-9ev3.md create mode 100644 .tickets/oa-9o9t.md create mode 100644 .tickets/oa-a6l7.md create mode 100644 .tickets/oa-acdb.md create mode 100644 .tickets/oa-aihi.md create mode 100644 .tickets/oa-ap7c.md create mode 100644 .tickets/oa-as4e.md create mode 100644 .tickets/oa-blgp.md create mode 100644 .tickets/oa-casy.md create mode 100644 .tickets/oa-cemz.md create mode 100644 .tickets/oa-cq7n.md create mode 100644 .tickets/oa-cxjk.md create mode 100644 .tickets/oa-d55a.md create mode 100644 .tickets/oa-de13.md create mode 100644 .tickets/oa-dpgg.md create mode 100644 .tickets/oa-dpm0.md create mode 100644 .tickets/oa-dygk.md create mode 100644 .tickets/oa-e07q.md create mode 100644 .tickets/oa-e6g3.md create mode 100644 .tickets/oa-e6gu.md create mode 100644 .tickets/oa-es45.md create mode 100644 .tickets/oa-eywk.md create mode 100644 .tickets/oa-f0p5.md create mode 100644 .tickets/oa-ff10.md create mode 100644 .tickets/oa-ft0e.md create mode 100644 .tickets/oa-fuc8.md create mode 100644 .tickets/oa-fyag.md create mode 100644 .tickets/oa-g8t3.md create mode 100644 .tickets/oa-gt0g.md create mode 100644 .tickets/oa-gtw8.md create mode 100644 .tickets/oa-hvd4.md create mode 100644 .tickets/oa-hysu.md create mode 100644 .tickets/oa-i56p.md create mode 100644 .tickets/oa-i8xb.md create mode 100644 .tickets/oa-it8h.md create mode 100644 .tickets/oa-ivm4.md create mode 100644 .tickets/oa-ivwp.md create mode 100644 .tickets/oa-ja73.md create mode 100644 .tickets/oa-jbvh.md create mode 100644 .tickets/oa-jm7x.md create mode 100644 .tickets/oa-jvyb.md create mode 100644 .tickets/oa-k5fx.md create mode 100644 .tickets/oa-ka08.md create mode 100644 .tickets/oa-lmh0.md create mode 100644 .tickets/oa-m5a8.md create mode 100644 .tickets/oa-n1fs.md create mode 100644 .tickets/oa-n86n.md create mode 100644 .tickets/oa-na7x.md create mode 100644 .tickets/oa-nam8.md create mode 100644 .tickets/oa-nhg0.md create mode 100644 .tickets/oa-nwha.md create mode 100644 .tickets/oa-of3b.md create mode 100644 .tickets/oa-p7ei.md create mode 100644 .tickets/oa-p829.md create mode 100644 .tickets/oa-p98b.md create mode 100644 .tickets/oa-pecx.md create mode 100644 .tickets/oa-plno.md create mode 100644 .tickets/oa-poi7.md create mode 100644 .tickets/oa-prjv.md create mode 100644 .tickets/oa-puhk.md create mode 100644 .tickets/oa-q2x1.md create mode 100644 .tickets/oa-qr52.md create mode 100644 .tickets/oa-qu7u.md create mode 100644 .tickets/oa-qv8d.md create mode 100644 .tickets/oa-qy0f.md create mode 100644 .tickets/oa-r0sq.md create mode 100644 .tickets/oa-r8yn.md create mode 100644 .tickets/oa-rde5.md create mode 100644 .tickets/oa-rnf3.md create mode 100644 .tickets/oa-ryve.md create mode 100644 .tickets/oa-s5jj.md create mode 100644 .tickets/oa-ssm2.md create mode 100644 .tickets/oa-t3tb.md create mode 100644 .tickets/oa-t4t2.md create mode 100644 .tickets/oa-tk1k.md create mode 100644 .tickets/oa-tqsm.md create mode 100644 .tickets/oa-tzta.md create mode 100644 .tickets/oa-ua2q.md create mode 100644 .tickets/oa-udp9.md create mode 100644 .tickets/oa-uiiw.md create mode 100644 .tickets/oa-v3js.md create mode 100644 .tickets/oa-vf6h.md create mode 100644 .tickets/oa-vg20.md create mode 100644 .tickets/oa-vnoe.md create mode 100644 .tickets/oa-vvep.md create mode 100644 .tickets/oa-wfgc.md create mode 100644 .tickets/oa-wmvc.md create mode 100644 .tickets/oa-wqyr.md create mode 100644 .tickets/oa-ww0m.md create mode 100644 .tickets/oa-wxf2.md create mode 100644 .tickets/oa-x9pe.md create mode 100644 .tickets/oa-xi7k.md create mode 100644 .tickets/oa-xr6w.md create mode 100644 .tickets/oa-yjii.md create mode 100644 .tickets/oa-yzwd.md create mode 100644 .tickets/oa-zbei.md diff --git a/.gitignore b/.gitignore index 00d29414..e78b29fc 100644 --- a/.gitignore +++ b/.gitignore @@ -73,5 +73,9 @@ detekt-*.html detekt-*.txt repomix-*.xml temp_research/ +BUG_FIXING.md +msg.txt +local-adb-screenshots/ +.opencode/package-lock.json *.png !screenshots/*.png diff --git a/.tickets/oa-0e8o.md b/.tickets/oa-0e8o.md new file mode 100644 index 00000000..69278356 --- /dev/null +++ b/.tickets/oa-0e8o.md @@ -0,0 +1,19 @@ +--- +id: oa-0e8o +status: closed +deps: [] +links: [] +created: 2026-05-07T14:48:05Z +type: bug +priority: 1 +assignee: Jasmin Le Roux +tags: [files, editor, license, cleanup] +--- +# Corrective batch for oa-hvd4 / oa-lmh0 reward hacks + +Council audit (post-oa-3rk7) flagged reward hacks: 1) WorkspaceFileRepository.toDomain drops FileContentDto.hash so baseline conflict detection is dead in default API path; 2) OfishBaselineHasher digests in-memory String not on-disk bytes producing false 409s on CRLF/BOM files; 3) Termux GPL-3.0 libs lack conveying-source notice (relinking notice covers LGPL only); 4) MIT grammar LicenseEntries marked version=null which catalogue doc reserves for 'planned, not shipped'; 5) Stale FilesViewModel comment claims read DTO has no hash; 6) confirmSave Ok branch bumps contentGeneration unnecessarily wiping cursor/undo on save; 7) _themeTypeAnchor dead val. + +## Acceptance Criteria + +DTO.hash maps through WorkspaceFileRepository, OFISH baseline matches on-disk hash (or caveat is honest about scope), GPL notice present, MIT entries versioned, dead code removed, tests cover real adapter not fake-only. + diff --git a/.tickets/oa-0f4m.md b/.tickets/oa-0f4m.md new file mode 100644 index 00000000..31fe4a4b --- /dev/null +++ b/.tickets/oa-0f4m.md @@ -0,0 +1,26 @@ +--- +id: oa-0f4m +status: closed +deps: [] +links: [] +created: 2026-05-01T17:44:25Z +type: task +priority: 1 +assignee: Jasmin Le Roux +parent: oa-gt0g +tags: [design, workspace, sessions] +--- +# Design lock C: optimistic mutation rollback contract + +Commit 5 ports SessionListViewModel optimistic mutations as reducer intents w/ rollback. Underspecified: which mutations are optimistic (delete/rename/share/unshare/summarize?), what triggers rollback (HTTP error / timeout / SSE contradiction?), user-visible feedback (silent revert / snackbar / toast?), reducer shape (pending intent + confirm/reject events?), interaction with concurrent SSE events on the same entity. + +## Acceptance Criteria + +1) Per-mutation table: optimistic-or-not, local state transition, rollback trigger, user-visible feedback. 2) Reducer intent/confirm/reject shapes defined. 3) Concurrent SSE event semantics defined (server confirmation arrives before HTTP response — what wins?). 4) Stale-workspace mutation rejection behavior defined. + + +## Notes + +**2026-05-01T18:19:06Z** + +Decision locked. See docs/design-locks/C-mutation-on-failure.md diff --git a/.tickets/oa-0lb9.md b/.tickets/oa-0lb9.md new file mode 100644 index 00000000..77d43d35 --- /dev/null +++ b/.tickets/oa-0lb9.md @@ -0,0 +1,40 @@ +--- +id: oa-0lb9 +status: closed +deps: [] +links: [] +created: 2026-05-10T09:55:10Z +type: task +priority: 2 +assignee: Jasmin Le Roux +--- +# Sweep stale OFISH sessions on startup or hydration + +Problem: +OFISH creates hidden background sessions for probes/mutations and relies on finally cleanup. If Android kills the app mid-operation, those sessions can remain on the OpenCode server. The stale sweep implementation exists but is not called. + +Evidence: +OfishSessionFactory.sweepStaleSessions(maxAgeMillis, limit) exists and filters OFISH-prefixed sessions, but repository search finds no production call sites. OFISH session names use the __ofish_ prefix. Capability/chunk/file operations create ephemeral OFISH sessions. + +UX Constraint: +Users should not see server session lists polluted by old hidden OFISH sessions, and cleanup should not delete active or user-visible sessions. Cleanup failures should be logged/human-readable only where appropriate, not block normal app startup. + +Expected Behavior: +The app periodically or opportunistically sweeps stale OFISH sessions for the active server/workspace lifecycle, such as on successful connection, hydration, or before first OFISH use. + +Acceptance Criteria: +- Call sweepStaleSessions from an appropriate workspace/server-scoped lifecycle point. +- Use conservative max age and limit values to avoid deleting active probes. +- Ensure sweep runs at most once per connection/workspace interval to avoid network spam. +- Log sweep results and failures without surfacing raw protocol errors to users. +- Add tests for stale session selection if feasible. + +Verification: +Run OFISH/session tests and ./gradlew :app:compileDebugKotlin. Manually create/leave a stale OFISH session and verify sweep removes it while preserving active sessions. + + +## Notes + +**2026-05-10T11:16:18Z** + +Wired stale OFISH session sweeping into OfishSessionFactory.withSession() before ephemeral session creation. Sweep is conservative: sessions must be at least 6 hours old, list is capped by existing default limit, active in-process OFISH sessions are skipped, and a process-wide per server/workspace guard runs at most once every 30 minutes. Sweep results/failures are logged only. Added unit coverage for once-per-workspace interval behavior. Verified with export JAVA_HOME=/usr/lib/jvm/java-17-openjdk && ./gradlew :app:testDebugUnitTest --tests dev.blazelight.p4oc.data.files.ofish.OfishSessionFactoryTest and ./gradlew :app:compileDebugKotlin. diff --git a/.tickets/oa-0mel.md b/.tickets/oa-0mel.md new file mode 100644 index 00000000..9fb15f47 --- /dev/null +++ b/.tickets/oa-0mel.md @@ -0,0 +1,52 @@ +--- +id: oa-0mel +status: open +deps: [] +links: [oa-dygk, oa-wmvc, oa-3yk2, oa-12ui] +created: 2026-07-05T18:06:47Z +type: bug +priority: 2 +assignee: Jasmin Le Roux +parent: oa-nwha +--- +# Resource tab and slash command display metadata + +Problem: +Tab titles and slash command descriptions include user-facing hardcoded display metadata outside a dedicated resource/UI boundary. This overlaps but is not identical to command dispatch correctness. + +Evidence: +Display-boundary audit identified TabBar.kt route/tab titles and ChatViewModel.kt/SlashCommandsPopup.kt built-in slash command descriptions as user-visible copy. oa-3yk2 owns command dispatch semantics; oa-dygk owns popup placement/metadata UX. This ticket owns resource-backed display text for tab/command metadata. + +UX Constraint: +Tab and command metadata must remain compact enough for phone screens and must not take persistent space unnecessarily. Labels/descriptions should be clear, localized where possible, and consistent between typed slash suggestions and command palette. + +Expected Behavior: +Tabs and slash commands expose stable ids/semantic types; UI maps them to resource-backed labels/descriptions. Server/custom command descriptions remain upstream-provided content and are not overwritten by Android hardcoded text unless classified as local built-ins. + +Acceptance Criteria: +- Move Android local tab titles and local built-in command descriptions to resources or centralized UI formatter. +- Preserve upstream command descriptions for server/custom/MCP/skill commands. +- Coordinate with oa-3yk2 so only commands classified as local built-ins receive Android resource metadata. +- Coordinate with oa-dygk so popup display metadata remains consistent. +- Add tests for local built-in display metadata versus upstream command metadata preservation. + +Verification: +Run targeted command metadata/popup/tab title tests and compile after implementation. + + +## Notes + +**2026-07-06T08:57:34Z** + +Workspace label source clarification from 2026-07-05 discussion: + +TabBar.getTabTitle currently takes workspaceDirectory: String? and builds suffixes from it at TabBar.kt:209-234. After oa-e6g3, TabState should store WorkspaceKey? instead of raw String? workspaceDirectory, so this ticket's tab title/resource work must derive workspace labels from WorkspaceKey rather than nullable directory strings. + +Expected label model after oa-e6g3: +- WorkspaceKey.Directory('/path/project-a') -> resource-backed suffix/display label such as 'project-a' and titles like 'Sessions · project-a'. +- WorkspaceKey.Global -> intentional top-level/server-wide tab. Product may choose either no suffix for default top-level views (e.g. 'Sessions') or a resource-backed 'Server-wide' suffix where clarity is needed. +- workspaceKey == null -> legacy/missing workspace recovery label, not the same as Global. +- WorkspaceKey.SessionScoped(sessionId) -> resolve to displayable workspace/session context before title formatting or show a recovery/loading label. + +Dependency note: +The tab title source should switch cleanly after oa-e6g3's TabState.workspaceKey change. Until then, avoid further entrenching workspaceDirectory:String? in new title-formatting APIs. diff --git a/.tickets/oa-0p94.md b/.tickets/oa-0p94.md new file mode 100644 index 00000000..6c2924de --- /dev/null +++ b/.tickets/oa-0p94.md @@ -0,0 +1,41 @@ +--- +id: oa-0p94 +status: closed +deps: [] +links: [] +created: 2026-05-10T13:33:04Z +type: task +priority: 2 +assignee: Jasmin Le Roux +--- +# Clean up Detekt unused-code baseline findings + +Problem: +Detekt is now wired into Gradle with app/detekt-baseline.xml. The baseline includes confirmed unused-code-style findings that should be cleaned up deliberately instead of bulk-deleting ambiguous Android/framework/Compose/resource-referenced code. + +Evidence: +app/detekt-baseline.xml currently contains NoUnusedImports entries for multiple files, UnusedParameter entries in AgentsConfigScreen.kt, SkillsScreen.kt, and StreamingMarkdown.kt, plus UnusedPrivateProperty entries for FilePickerManager.kt, MainTabScreen.kt, and MdnsDiscoveryManager.kt. + +UX Constraint: +Dead-code cleanup must be safe and boring. Do not delete public/API/framework/Compose preview/serialization/reflection/resource-referenced declarations without verifying usage. Avoid UI or behavior changes unless the code is proven unused. + +Expected Behavior: +Remove or suppress confirmed unused imports/parameters/private properties where safe, shrink the Detekt baseline accordingly, and keep ./gradlew :app:detekt passing. + +Acceptance Criteria: +- Review the NoUnusedImports, UnusedParameter, and UnusedPrivateProperty IDs in app/detekt-baseline.xml. +- Remove confirmed unused imports and private declarations where safe. +- For unused parameters that are part of callbacks, overrides, Compose APIs, or expected external signatures, keep them and use an explicit suppression or rename strategy if needed. +- Regenerate or manually shrink app/detekt-baseline.xml only for fixed findings. +- Verify ./gradlew :app:detekt and ./gradlew :app:compileDebugKotlin. + +Verification: +export JAVA_HOME=/usr/lib/jvm/java-17-openjdk && ./gradlew :app:detekt +export JAVA_HOME=/usr/lib/jvm/java-17-openjdk && ./gradlew :app:compileDebugKotlin + + +## Notes + +**2026-05-10T14:25:44Z** + +Partial cleanup applied: removed stale read-only toggle callbacks, markdown streaming pass-through parameter, FilePickerManager fileRepository constructor dependency, MainTabScreen focus/keyboard unused locals, and MdnsDiscoveryManager DEFAULT_SERVER_PORT. compileDebugKotlin passes. Detekt is not clean: after removing the baseline IDs it reports remaining NoUnusedImports plus unrelated pre-existing/baseline-churn style findings; broad detekt --auto-correct was attempted and should be reviewed separately before closing. diff --git a/.tickets/oa-0qgz.md b/.tickets/oa-0qgz.md new file mode 100644 index 00000000..b125288c --- /dev/null +++ b/.tickets/oa-0qgz.md @@ -0,0 +1,39 @@ +--- +id: oa-0qgz +status: closed +deps: [oa-s5jj] +links: [] +created: 2026-05-05T18:19:53Z +type: task +priority: 2 +assignee: Jasmin Le Roux +parent: oa-ssm2 +tags: [files, ofish, benchmark] +--- +# Empirical OFISH chunk-size benchmark + runtime probe + +JVM benchmark script that drives OpenCodeApi.executeShellCommand against a running opencode serve to find the optimal chunk size empirically. No hard upper cap. + +Run target: PENCODE_SERVER_PASSWORD=hunter2 opencode serve --hostname 0.0.0.0 --port 4096 (user: opencode). + +Bench script (app/src/test/java/dev/blazelight/p4oc/bench/ChunkSizeBenchmark.kt): +1. Create ephemeral session. +2. Probe sizes geometrically: 64 KiB, 256 KiB, 1 MiB, 4 MiB, 16 MiB, 64 MiB, ... DOUBLE until first failure (no upper cap in algorithm). +3. For each size: emit base64 payload via heredoc-on-stdin OFISH command, sha256 verify, measure latency. +4. Pick highest-throughput SUCCESSFUL size as the constant. +5. Delete session, clean temp file. + +Output: const val OFISH_DEFAULT_CHUNK_BYTES: Int = . + +Runtime probe at workspace connect (in OfishSessionProvider equivalent): +- Try one probe write at OFISH_DEFAULT_CHUNK_BYTES. +- If success: use constant. +- If fail: halve until success or 64 KiB minimum. +- NEVER probe upward — only the manual benchmark grows the constant. + +Re-run benchmark when server, proxy, OkHttp, or deployment config changes. + +## Acceptance Criteria + +Benchmark script runs against the dev server in less than 60s. Outputs a chunk size with measured throughput. Constant committed to source. Runtime probe halves correctly on simulated server-rejection. No hard upper cap anywhere in the chunking code path. + diff --git a/.tickets/oa-0r8n.md b/.tickets/oa-0r8n.md new file mode 100644 index 00000000..54e6a82d --- /dev/null +++ b/.tickets/oa-0r8n.md @@ -0,0 +1,32 @@ +--- +id: oa-0r8n +status: closed +deps: [] +links: [] +created: 2026-05-10T09:51:33Z +type: bug +priority: 1 +assignee: Jasmin Le Roux +--- +# Raise OkHttp per-host limits for persistent SSE and PTY sockets + +Problem: +The app uses persistent SSE and PTY WebSocket connections to the same host. OkHttp's default Dispatcher maxRequestsPerHost is 5, so several terminal tabs plus SSE can starve ordinary REST calls behind long-lived connections. + +Evidence: +ConnectionManager builds REST/SSE/WebSocket clients from a shared base OkHttpClient/ConnectionPool but does not configure Dispatcher.maxRequestsPerHost. SSE holds a long-lived connection, and each terminal tab uses a WebSocket. OkHttp defaults can queue additional requests per host when the limit is reached. + +UX Constraint: +Opening multiple terminal tabs must not make chat sends, project/session loading, file operations, or settings API calls hang indefinitely. + +Expected Behavior: +ConnectionManager configures an OkHttp Dispatcher appropriate for multiple persistent connections per server, and all derived clients share or use a compatible dispatcher policy. + +Acceptance Criteria: +- Set maxRequestsPerHost high enough for expected concurrent SSE/WebSocket/REST usage, with a documented rationale. +- Verify derived REST/SSE/WebSocket clients use the intended Dispatcher policy. +- Preserve connection pooling and auth behavior. +- Add a test or manual verification scenario with SSE + multiple terminals + REST request. + +Verification: +Run ./gradlew :app:compileDebugKotlin. Manually open several terminal tabs and verify session/project/file REST calls still complete. diff --git a/.tickets/oa-12ui.md b/.tickets/oa-12ui.md new file mode 100644 index 00000000..4f0c4f72 --- /dev/null +++ b/.tickets/oa-12ui.md @@ -0,0 +1,36 @@ +--- +id: oa-12ui +status: open +deps: [oa-wmvc, oa-3yk2, oa-qy0f, oa-wxf2, oa-ivwp, oa-vf6h, oa-prjv, oa-3l1w, oa-0mel, oa-9ev3, oa-n1fs, oa-gtw8, oa-tzta, oa-plno, oa-ua2q, oa-e6g3, oa-77dh] +links: [oa-prjv, oa-tzta, oa-n1fs, oa-qy0f, oa-e6g3, oa-9ev3, oa-wmvc, oa-gtw8, oa-plno, oa-wxf2, oa-vf6h, oa-3l1w, oa-0mel, oa-ivwp, oa-3yk2, oa-ua2q, oa-77dh] +created: 2026-07-05T18:04:23Z +type: task +priority: 1 +assignee: Jasmin Le Roux +parent: oa-nwha +--- +# Rewrite tests that preserve stale implementation contracts + +Problem: +Audit found tests that lock in current implementation details or incorrect fallback behavior instead of intended user/upstream contracts. These tests can make future correct fixes look like regressions. + +Evidence: +Examples include EventMapperTest.kt and older ToolStateExtTest.kt asserting English permission title behavior, ChatViewModelTest.kt previously missing command palette dispatch paths, ModelAgentManagerTest.kt and ModelReasoningEffortsTest.kt encoding fragile model/reasoning defaults, TabChatRouteCodecTest.kt and TabManagerPersistenceTest.kt preserving older route/tab shapes, ChatScrollRestorationTest.kt inspecting ChatScreen.kt source strings, and ChatScreenScrollRestorationTest.kt existing but blocked by androidTest harness issues. + +UX Constraint: +Tests should protect user-visible behavior and architectural contracts, not implementation accidents. Red tests should fail for the intended reason and should not require future agents to rediscover why a bad fallback was once expected. + +Expected Behavior: +Tests assert protocol/domain/UI boundaries, typed versus palette command behavior, explicit default precedence, workspace scoping, and lifecycle restoration behavior. Source-inspection tests are temporary only and replaced by behavior tests where possible. + +Acceptance Criteria: +- Inventory stale-contract tests from audit and tag each as keep, rewrite, or delete. +- Replace English/domain permission assertions with raw data plus resource/UI formatter assertions. +- Ensure command tests cover typed and palette dispatch paths for local/session/server commands. +- Rewrite model/reasoning tests to assert server/config/user-default precedence without first-item fallback assumptions. +- Replace ChatScrollRestorationTest source guard with androidTest behavior coverage after the coroutine ServiceLoader blocker is fixed. +- Update route/tab persistence tests to assert current workspace/session identity requirements, not legacy compatibility guessing. + +Verification: +Run targeted tests for each rewritten area and confirm failures, if any, point to production contract gaps rather than stale test expectations. + diff --git a/.tickets/oa-16lj.md b/.tickets/oa-16lj.md new file mode 100644 index 00000000..aed8d61d --- /dev/null +++ b/.tickets/oa-16lj.md @@ -0,0 +1,40 @@ +--- +id: oa-16lj +status: closed +deps: [] +links: [] +created: 2026-05-10T09:45:36Z +type: task +priority: 2 +assignee: Jasmin Le Roux +--- +# Replace custom unified diff parsing with java-diff-utils mapping + +Problem: +ParsedDiffParser manually parses unified diff sections, headers, hunks, and line numbers even though java-diff-utils is already imported and used for validation. This duplicates library behavior and increases parser bug surface. + +Evidence: +app/src/main/java/dev/blazelight/p4oc/ui/diff/ParsedDiff.kt contains ParsedDiffParser with custom splitIntoFileSections(), hunk regex parsing, HunkBuilder, and line-number tracking. It calls UnifiedDiffUtils.parseUnifiedDiff(linesForLibrary) only to validate hunk count, then discards the Patch object and parses manually. + +UX Constraint: +Diff rendering must remain stable for git-style multi-file diffs, headerless unified hunks, /dev/null create/delete cases, and files with timestamps in headers. Bad or unsupported diffs should fail gracefully in UI. + +Expected Behavior: +Use java-diff-utils as the parser source of truth where it can represent the diff, then map its Patch/delta model into ParsedDiff/ParsedHunk/ParsedDiffLine or a simpler UI model. Keep only minimal glue for file-section metadata the library does not expose. + +Acceptance Criteria: +- Delete or substantially reduce custom hunk parsing logic in ParsedDiffParser. +- Use UnifiedDiffUtils/Patch/delta data for hunk and line content mapping. +- Preserve existing ParsedDiffParserTest behavior or intentionally update tests with documented behavior changes. +- Keep support for multiple file sections and headerless fallback if still needed. +- Add regression tests for create/delete, multiple hunks, and malformed diff handling. + +Verification: +Run ./gradlew :app:testDebugUnitTest --tests dev.blazelight.p4oc.ui.diff.ParsedDiffParserTest and ./gradlew :app:compileDebugKotlin. + + +## Notes + +**2026-05-10T11:22:58Z** + +Started refactor of ParsedDiffParser to map java-diff-utils Patch/delta output into ParsedHunk while keeping minimal file-section/header glue for metadata and context row reconstruction. Removed HunkBuilder/custom hunk object construction. Targeted verification is currently blocked before tests run by unrelated SettingsScreen missing string resource compile errors: settings_help, status_legend_* and related IDs. Ticket remains in_progress until those external compile errors are resolved and ParsedDiffParserTest can be rerun. diff --git a/.tickets/oa-19hl.md b/.tickets/oa-19hl.md new file mode 100644 index 00000000..7c7115ad --- /dev/null +++ b/.tickets/oa-19hl.md @@ -0,0 +1,38 @@ +--- +id: oa-19hl +status: closed +deps: [] +links: [] +created: 2026-05-10T09:52:39Z +type: bug +priority: 2 +assignee: Jasmin Le Roux +--- +# Evict OkHttp connection pool on disconnect + +Problem: +ConnectionManager nulls active Retrofit/WebSocket clients on disconnect but leaves the shared OkHttp ConnectionPool alive. Idle TCP sockets to an old server can remain open until keepAlive expiration, wasting resources and potentially interacting poorly with server switching. + +Evidence: +ConnectionManager owns sharedConnectionPool with keepAliveDuration 5 minutes. disconnect() cancels SSE forwarding, disconnects the active connection, nulls _connection and _authOkHttpClient, and sets Disconnected, but does not call sharedConnectionPool.evictAll(). + +UX Constraint: +Disconnecting or switching servers should fully release old server network resources. Reconnects should remain fast enough without keeping stale sockets across explicit disconnect. + +Expected Behavior: +Explicit disconnect/server switch evicts idle pooled sockets for the old connection after active streams are closed. + +Acceptance Criteria: +- Call sharedConnectionPool.evictAll() during explicit disconnect after closing active SSE/WebSocket resources. +- Ensure reconnectSse does not evict the pool for lightweight reconnects unless intentionally needed. +- Confirm active connection close ordering prevents leaked sockets. + +Verification: +Run ./gradlew :app:compileDebugKotlin and manually connect/disconnect/switch servers without regressions. + + +## Notes + +**2026-05-10T11:10:44Z** + +Implemented explicit sharedConnectionPool.evictAll() in ConnectionManager.disconnect() after active SSE/WebSocket connection resources are disconnected and before connection/client references are cleared. reconnectSse remains lightweight and does not evict the pool. Verified with export JAVA_HOME=/usr/lib/jvm/java-17-openjdk && ./gradlew :app:compileDebugKotlin. diff --git a/.tickets/oa-1bz6.md b/.tickets/oa-1bz6.md new file mode 100644 index 00000000..cf2afb99 --- /dev/null +++ b/.tickets/oa-1bz6.md @@ -0,0 +1,40 @@ +--- +id: oa-1bz6 +status: closed +deps: [] +links: [] +created: 2026-05-10T09:49:42Z +type: bug +priority: 2 +assignee: Jasmin Le Roux +--- +# Move uploads out of screen ViewModel scopes + +Problem: +UploadCoordinator jobs are launched in screen ViewModel scopes. Uploads started from chat or files are cancelled when the owning screen ViewModel is cleared, even if the tab/app still exists and the user expects the upload to continue. + +Evidence: +ChatViewModel constructs FilePickerManager(workspaceClient, viewModelScope). FilePickerManager constructs UploadCoordinator(scope = scope). FilesViewModel constructs UploadCoordinator(scope = viewModelScope). UploadCoordinator owns upload progress state and runs file uploads through the provided scope. + +UX Constraint: +File uploads should continue across navigation within the tab and should expose progress/failure state in a human-readable way. Closing the owning tab or disconnecting the workspace should cancel uploads deliberately. + +Expected Behavior: +Uploads are owned by a tab/workspace/app-level upload service or repository scope, not by transient chat/files screen ViewModel scopes. UI screens observe upload state. + +Acceptance Criteria: +- Introduce a scoped upload owner/service tied to tab/workspace lifecycle, not individual screens. +- Chat and Files screens share/observe upload state for the same workspace where appropriate. +- Closing the tab, changing workspace, or disconnecting cancels uploads intentionally. +- Preserve upload progress, retry behavior, and human-readable errors. +- Avoid global/default workspace state; upload owner must be workspace-scoped. + +Verification: +Run upload-related tests and ./gradlew :app:compileDebugKotlin. Manually start an upload, navigate away from chat/files, and confirm expected continuation/cancellation behavior. + + +## Notes + +**2026-05-10T11:47:00Z** + +Implemented workspace/tab-scoped upload ownership in WorkspaceRepositoryOwner. Chat and Files now share the workspace upload coordinator; upload jobs are no longer launched in screen ViewModel scopes and are cancelled when the workspace owner closes. Verification: export JAVA_HOME=/usr/lib/jvm/java-17-openjdk && ./gradlew :app:compileDebugKotlin passed. diff --git a/.tickets/oa-1csc.md b/.tickets/oa-1csc.md new file mode 100644 index 00000000..b29235c8 --- /dev/null +++ b/.tickets/oa-1csc.md @@ -0,0 +1,41 @@ +--- +id: oa-1csc +status: closed +deps: [] +links: [] +created: 2026-05-10T09:41:55Z +type: task +priority: 2 +assignee: Jasmin Le Roux +--- +# Stream file uploads instead of materializing full ByteArray + +Problem: +File uploads currently read the selected content URI into a full ByteArray before uploading. A 25 MiB file can require a contiguous 25 MiB allocation plus base64/protocol overhead in OFISH, which is risky on memory-constrained Android devices and can crash with OutOfMemoryError. + +Evidence: +app/src/main/java/dev/blazelight/p4oc/ui/screens/files/upload/ContentResolverUploadSource.kt uses ByteArrayOutputStream. UploadOrchestrator.DEFAULT_MAX_BYTES is 25 MiB. FileUploadRequest in app/src/main/java/dev/blazelight/p4oc/data/files/FileRepository.kt stores bytes: ByteArray. OFISH upload then chunks/base64 encodes from this in-memory payload. + +UX Constraint: +Uploading project files must not freeze or crash the app. Progress should remain accurate and failures should be human-readable, not raw protocol/JSON output. + +Expected Behavior: +Upload sources stream file content into repository/mutation code using InputStream or Flow chunks. The app validates size limits before or during streaming and never requires the whole file plus encoded copy in memory. + +Acceptance Criteria: +- Replace FileUploadRequest.bytes with a streaming content source abstraction, or otherwise avoid materializing the full file in memory. +- Preserve expectedHash and progress callback semantics. +- OFISH upload remains chunked and reports progress per uploaded bytes. +- Unsupported REST mutations still fail cleanly without attempting to consume the stream. +- Size-limit failures are human-readable and happen before excessive memory allocation. +- Add unit tests for chunking/progress and oversized-file failure where feasible. + +Verification: +Run ./gradlew :app:testDebugUnitTest for file/upload tests and ./gradlew :app:compileDebugKotlin. Manually upload a small file and a file near the configured limit. + + +## Notes + +**2026-05-10T11:57:47Z** + +Implemented streaming upload pipeline without a total file-size cap. UploadSource now opens streams instead of returning full ByteArrays; FileUploadRequest carries contentLength plus an openStream callback; OFISH reads and uploads one configured chunk at a time while reporting uploaded bytes. Removed the old 25 MiB orchestrator cap and updated focused upload/repository tests. Verification: focused upload unit tests passed and ./gradlew :app:compileDebugKotlin passed. diff --git a/.tickets/oa-1n6h.md b/.tickets/oa-1n6h.md new file mode 100644 index 00000000..1b9a5d6b --- /dev/null +++ b/.tickets/oa-1n6h.md @@ -0,0 +1,60 @@ +--- +id: oa-1n6h +status: closed +deps: [] +links: [oa-a6l7, oa-t4t2, oa-764s, oa-p7ei, oa-es45] +created: 2026-04-19T13:58:33Z +type: feature +priority: 2 +assignee: Jasmin Le Roux +external-ref: pr-3-cherrypick +tags: [ui, perf, theme, startup] +--- +# Async theme preload — no more blank first frame + +Port OptimizedThemeLoader pattern from PR #3 branch pr-3 (files app/src/main/java/dev/blazelight/p4oc/ui/theme/opencode/OptimizedThemeLoader.kt and ThemeCacheManager.kt). First frame uses hardcoded fallback ColorScheme from createFallbackTheme(isDark); real theme JSON loads async on Dispatchers.IO; Compose recomposes with real theme when ready. + +## What to port + +From pr-3: +- app/src/main/java/dev/blazelight/p4oc/ui/theme/opencode/OptimizedThemeLoader.kt (full file) +- app/src/main/java/dev/blazelight/p4oc/ui/theme/opencode/ThemeCacheManager.kt (full file) +- Theme.kt wiring so loadThemeImmediate() is the primary Compose-time read + +## Fix PR #3's runBlocking + +SettingsDataStore.init in pr-3 uses runBlocking to synchronously load cached values. DO NOT PORT THAT. + +Instead: +- Keep `@Volatile var cachedThemeName: String = DEFAULT_THEME_NAME` (starts with default) +- Fire async scope.launch in init that reads DataStore and updates cached values +- First composition reads DEFAULT_THEME_NAME; when async read completes, LiveData/Flow triggers recomposition with the saved theme +- Apply same treatment to cachedServerUrl and cachedUsername (already Flow-based on main, just confirm no regression) + +## Do NOT + +- runBlocking anywhere in init paths +- Change DEFAULT_THEME_NAME from catppuccin to dracula (rejected) +- Add DATASTORE_DEBUG Log.d (rejected) +- Port anything UI-side from pr-3 (kotlin theming only) + +## Blocks on + +- oa-t4t2 (PR E) to measure first-frame improvement + +## Verify + +- Cold start on POCO: measure time from icon tap to first content paint +- Before: blank/default first frame for ~200-400ms +- After: default theme first frame instant, real theme in < 100ms +- No change in any user-visible theme behavior after initial load + +## Acceptance Criteria + +1. Compiles cleanly +2. Cold-start first frame shows default catppuccin fallback within 50ms +3. Real theme replaces within 200ms +4. No runBlocking in SettingsDataStore +5. Default theme stays catppuccin on fresh install +6. Benchmark shows improvement in StartupBenchmark (from PR E) + diff --git a/.tickets/oa-1ond.md b/.tickets/oa-1ond.md new file mode 100644 index 00000000..9019f659 --- /dev/null +++ b/.tickets/oa-1ond.md @@ -0,0 +1,39 @@ +--- +id: oa-1ond +status: closed +deps: [] +links: [] +created: 2026-05-10T09:45:46Z +type: task +priority: 2 +assignee: Jasmin Le Roux +--- +# Avoid main-thread question queue JSON serialization + +Problem: +DialogQueueManager serializes pending question state to JSON synchronously while handling question queue changes. If QuestionRequest payloads become large, this can add main-thread work during SSE-driven UI updates. + +Evidence: +DialogQueueManager.showNextQuestion() writes savedStateHandle[KEY_PENDING_QUESTION] = json.encodeToString(question). persistQuestionsQueue() builds pendingQuestions.toList() and writes json.encodeToString(queueList). DialogQueueManager is used from ChatViewModel UI/event paths. + +UX Constraint: +Permission/question prompts must remain responsive and survive process death, but serialization should not cause visible frame drops or input lag. Failure states should be logged and recover by clearing invalid saved state, not crashing. + +Expected Behavior: +Question persistence does any non-trivial JSON encoding off the main thread, then applies the resulting string to SavedStateHandle on the main thread. StateFlow prompt updates remain immediate. + +Acceptance Criteria: +- Move pending question/queue JSON encoding off the main thread, or otherwise prove payload size is bounded enough to keep synchronous encoding. +- Avoid races where stale async persistence overwrites newer queue state. +- Preserve process-death restoration behavior. +- Add focused tests for enqueue/clear ordering if the implementation introduces async persistence. + +Verification: +Run ChatViewModel/DialogQueueManager tests and ./gradlew :app:compileDebugKotlin. + + +## Notes + +**2026-05-10T11:44:55Z** + +Implemented async DialogQueueManager question persistence using injected coroutine scope/dispatcher, Default dispatcher JSON encoding, and version guards to prevent stale writes. Added focused tests for async enqueue/clear ordering. Verification attempted with ./gradlew :app:testDebugUnitTest --tests dev.blazelight.p4oc.ui.screens.chat.DialogQueueManagerTest, but build is currently blocked before tests by unrelated TabBar.kt compile errors: unresolved SessionStateColors and Color type mismatch. diff --git a/.tickets/oa-339m.md b/.tickets/oa-339m.md new file mode 100644 index 00000000..3adbeaaf --- /dev/null +++ b/.tickets/oa-339m.md @@ -0,0 +1,40 @@ +--- +id: oa-339m +status: closed +deps: [] +links: [] +created: 2026-05-10T09:55:39Z +type: chore +priority: 3 +assignee: Jasmin Le Roux +--- +# Delete dynamic OFISH upload chunk probing + +Problem: +OFISH upload chunk-size probing dynamically runs shell commands to determine upload chunk size. This adds complexity, latency, caching, and benchmark code for a value that may be safely fixed conservatively. + +Evidence: +OfishUploadChunkProbe.kt, CachedOfishUploadChunkBytes, OfishUploadChunkProbeCommandBuilder, and app/src/test/java/dev/blazelight/p4oc/bench/ChunkSizeBenchmark.kt exist. OfishMutationClient depends on the chunk cache and has fallback behavior when the probe is unavailable. + +UX Constraint: +Uploads should be reliable and predictable. Removing dynamic probing must not exceed shell command size limits or cause large-file upload regressions. Failures must be human-readable. + +Expected Behavior: +Use a conservative fixed OFISH upload chunk size constant unless a concrete server/platform limit requires dynamic probing. + +Acceptance Criteria: +- Evaluate current probe benefits vs a fixed chunk size such as 512 KiB. +- If safe, delete OfishUploadChunkProbe, CachedOfishUploadChunkBytes, and ChunkSizeBenchmark. +- Simplify OfishMutationClient and FileRepositoryFactory accordingly. +- Preserve upload progress and hashing semantics. +- Add tests for command construction/chunking with the fixed size. + +Verification: +Run OFISH upload tests and ./gradlew :app:compileDebugKotlin. Manually upload files of representative sizes. + + +## Notes + +**2026-05-10T11:28:26Z** + +Implemented fixed 256 KiB OFISH upload chunks. Removed dynamic upload chunk probe/cache code and manual chunk benchmark. FileRepositoryFactory now relies on OfishMutationClient default chunk provider. Updated OFISH mutation tests to assert default 256 KiB chunking. Verified with ./gradlew :app:testDebugUnitTest --tests 'dev.blazelight.p4oc.data.files.ofish.*' and JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew :app:compileDebugKotlin. diff --git a/.tickets/oa-3l1w.md b/.tickets/oa-3l1w.md new file mode 100644 index 00000000..9a77248f --- /dev/null +++ b/.tickets/oa-3l1w.md @@ -0,0 +1,34 @@ +--- +id: oa-3l1w +status: open +deps: [] +links: [oa-wmvc, oa-12ui] +created: 2026-07-05T18:06:47Z +type: bug +priority: 2 +assignee: Jasmin Le Roux +parent: oa-nwha +--- +# Resource file language fallback labels + +Problem: +File language fallback labels such as plain text are hardcoded instead of resource-backed presentation text. + +Evidence: +Display-boundary audit identified SoraLanguageRegistry.kt language fallback labels, including plain text, as user-facing copy outside a resource boundary. + +UX Constraint: +File language labels should be concise and localizable, and should not confuse protocol/file-extension identifiers with display names. + +Expected Behavior: +Language detection returns stable technical language ids/kinds. UI presentation maps them to resource-backed names, with unknown/plain-text fallbacks handled consistently. + +Acceptance Criteria: +- Separate language id/kind from display label. +- Move plain-text and unknown-language display labels to resources or a UI formatter. +- Preserve extension/language identifiers for syntax logic only, not as localized labels. +- Add tests for known, plain-text, and unknown fallback display behavior. + +Verification: +Run targeted file/language registry tests and compile after implementation. + diff --git a/.tickets/oa-3rk7.md b/.tickets/oa-3rk7.md new file mode 100644 index 00000000..fa7a8669 --- /dev/null +++ b/.tickets/oa-3rk7.md @@ -0,0 +1,16 @@ +--- +id: oa-3rk7 +status: closed +deps: [] +links: [] +created: 2026-05-07T10:03:00Z +type: task +priority: 2 +assignee: Jasmin Le Roux +parent: oa-ssm2 +tags: [files, editor, sora, textmate] +--- +# Add curated TextMate grammars for Sora editor + +Sora editor v1 uses EmptyLanguage plain-text fallback with OpenCode-themed chrome. Add a small curated TextMate grammar bundle for common mobile coding files (.env, json, kt, java, js/ts, sh, py, md, yaml, toml, xml/gradle), map filenames to scope names, and keep APK size delta documented. Acceptance: edit mode shows token highlighting for at least .env/json/kotlin using LocalOpenCodeTheme syntax colors. + diff --git a/.tickets/oa-3vga.md b/.tickets/oa-3vga.md new file mode 100644 index 00000000..d71679c2 --- /dev/null +++ b/.tickets/oa-3vga.md @@ -0,0 +1,40 @@ +--- +id: oa-3vga +status: closed +deps: [] +links: [] +created: 2026-05-10T09:45:25Z +type: bug +priority: 1 +assignee: Jasmin Le Roux +--- +# Make SSE reconnection policy single-owner + +Problem: +SSE reconnection is controlled by both LaunchDarkly BackgroundEventSource retry behavior and UI-layer timers in MainTabScreen. Parallel reconnect policies can race, tearing down a recovering stream or causing duplicated reconnect attempts/log noise. + +Evidence: +OpenCodeEventSource configures ErrorStrategy.alwaysContinue() and retryDelay(3, TimeUnit.SECONDS). MainTabScreen observes ConnectionState and, after reconnectTimeoutSeconds or Disconnected recovery delays, calls connectionManager.reconnectSse(reason = ...). Foreground resume also calls reconnectSse from UI state. + +UX Constraint: +Network drops should produce stable, understandable connection state without flickering, duplicate reconnect spam, or unnecessary navigation back to the server screen. User settings such as autoReconnect and reconnectTimeoutSeconds should still be honored in one place. + +Expected Behavior: +OpenCodeEventSource/ConnectionManager own retry, timeout, and final disconnect/escalation policy. MainTabScreen reacts to connection states and navigates only on terminal disconnected states; it does not run competing retry timers. + +Acceptance Criteria: +- Define one reconnection owner and move timeout/escalation logic there. +- Remove UI-layer delayed reconnect loops from MainTabScreen, except lifecycle foreground hooks if explicitly justified and race-safe. +- Preserve autoReconnect=false behavior. +- Preserve human-readable connection error states/settings. +- Add tests or fakes for retry exhaustion/escalation where feasible. + +Verification: +Run ./gradlew :app:compileDebugKotlin and manually test server stop/start or network loss to confirm reconnection behavior and navigation. + + +## Notes + +**2026-05-10T10:25:08Z** + +Made ConnectionManager the single owner for SSE reconnect timeout/escalation. MainTabScreen now delegates foreground resume to ConnectionManager and only reacts to terminal Disconnected states; it no longer runs delayed reconnect loops or final explicit reconnect attempts. ConnectionManager reads connectionSettings for autoReconnect and reconnectTimeoutSeconds, cancels escalation when SSE recovers, escalates Error to Disconnected via the active OpenCodeEventSource, and preserves LaunchDarkly retry ownership. Verification: export JAVA_HOME=/usr/lib/jvm/java-17-openjdk && ./gradlew :app:compileDebugKotlin; ./gradlew :app:testDebugUnitTest --tests dev.blazelight.p4oc.core.network.OpenCodeEventSourceTest. Manual server stop/start still recommended for final release gate. diff --git a/.tickets/oa-3yk2.md b/.tickets/oa-3yk2.md new file mode 100644 index 00000000..b6a81ead --- /dev/null +++ b/.tickets/oa-3yk2.md @@ -0,0 +1,86 @@ +--- +id: oa-3yk2 +status: closed +deps: [] +links: [oa-nwha, oa-0mel, oa-12ui, oa-casy, oa-dygk, oa-wmvc, oa-qy0f, oa-wxf2] +created: 2026-07-05T16:59:36Z +type: bug +priority: 1 +assignee: Jasmin Le Roux +parent: oa-casy +--- +# Fix undo and redo slash command dispatch contracts + +Problem: +Android advertises /undo and /redo as built-in slash commands, but the chat typed-command path and command palette path are routed through generic executeCommand. Undo/redo are destructive session/file semantics and need explicit dispatch matching the chosen opencode contract, not accidental server-command fallback. + +Evidence: +- ChatViewModel.kt:142-165 hardcodes undo/redo in BUILTIN_COMMANDS. +- ChatViewModel.kt:347-353 parses slash commands and calls executeCommand. +- ChatViewModel.kt:563-570 builds ExecuteCommandRequest and calls workspaceClient.executeCommand. +- ChatScreen.kt:492-500 command palette also calls viewModel.executeCommand(command.name, args). +- WorkspaceClient.kt:92-95 already exposes revert/unrevert APIs; ChatViewModel.kt:604-630 has revertSession/unrevertSession helpers. +- Audit noted that upstream TUI /undo removes the most recent user message/responses/file changes using Git, while /redo restores previously undone state; generic server command resolution is not the right boundary for TUI/session actions. + +UX Constraint: +Undo/redo must not silently fail as unknown commands, create chat prompt text, or cross workspace/session boundaries. If undo/redo cannot run because of missing Git/session state, surface a human-readable error from the explicit handler. + +Expected Behavior: +Typing /undo or selecting undo from the palette dispatches to the explicit undo handler for the current session/workspace. Typing /redo or selecting redo dispatches to the explicit redo handler for the current session/workspace. Neither path uses generic executeCommand unless the team deliberately verifies upstream server-command semantics and encodes that as the contract. + +## Design + +Introduce a sealed/local command semantics table before branching ad hoc in multiple UI paths. Keep server API commands distinct from Android local/session commands. The command palette should invoke the same dispatcher as typed slash input. + +## Acceptance Criteria + +- Add failing red unit tests for typed /undo and /redo asserting the exact desired dispatch and non-use of the wrong path. +- Add failing red tests for command palette undo/redo dispatch, not only typed slash input. +- Clarify and encode redo semantics: unrevert existing reverted state versus next-boundary revert behavior; tests must name the chosen behavior. +- Production code routes /undo and /redo through a single explicit command dispatcher shared by typed input and palette. +- Generic executeCommand remains reserved for server/custom/MCP/skill commands. +- Workspace/session identity comes from the current ChatViewModel/WorkspaceClient; no global/default workspace fallback. +- Verification: run targeted ChatViewModel command tests and ./gradlew :app:compileDebugKotlin. + + +## Notes + +**2026-07-05T18:05:09Z** + +Broader command audit findings folded into this dispatcher ticket on 2026-07-05: + +Do not treat this as only /undo and /redo. The same explicit-dispatcher fix should classify all audited hardcoded built-ins before falling back to generic server/custom/MCP/skill command execution. + +Additional commands to classify and test: +- /compact: route to the verified summarize/compaction/session API if supported; otherwise show unsupported/degraded UI. +- /summarize: add/verify alias behavior if supported upstream/TUI. +- /clear and /new: local/session navigation or session lifecycle actions, not generic executeCommand unless upstream contract says otherwise. +- /share and /unshare: use existing share/unshare session APIs if present. +- /help and /connect: local UI/settings/help actions. +- /bug: verify upstream/TUI semantics before exposing; unsupported must be human-readable. + +Built-in shadowing risk: +Hardcoded Android commands are currently prepended before server commands and de-duplicated by name. The dispatcher/command source model must avoid Android metadata shadowing server/custom/MCP/skill commands unless the command is intentionally reserved as a local/session built-in. + +Acceptance addendum: +Typed slash input and palette selection must share one dispatcher for every classified built-in, and generic executeCommand remains only for commands classified as server/custom/MCP/skill. + +**2026-07-06T11:02:26Z** + +Completion update from 2026-07-06: + +Implemented the explicit shared dispatcher for undo/redo slash commands. + +Production changes: +- ChatViewModel.executeCommand now classifies local session commands before server command execution. +- Typed slash input and command palette selection both flow through executeCommand, so /undo and /redo now share one dispatcher. +- undo dispatches to the previous user-message revert boundary using the current session/messages and WorkspaceClient.revertSession; it no longer calls executeCommand. +- redo uses the encoded next-boundary revert semantics: when an active revert points at a user message, redo advances to the next user-message boundary with revertSession; it does not call unrevertSession or generic executeCommand. +- Missing undo/redo boundaries surface human-readable errors: Nothing to undo / Nothing to redo. +- Existing direct revertMessage and unrevertSession UI helpers remain available for row/button actions. + +Verification: +- ./gradlew :app:testDebugUnitTest --tests dev.blazelight.p4oc.ui.screens.chat.ChatViewModelTest -> PASS +- ./gradlew :app:compileDebugKotlin -> PASS +- ./gradlew :app:detekt -> PASS +- ./gradlew :app:testDebugUnitTest -> expected FAIL with only two remaining mapped red tests outside oa-3yk2: ToolStateExtTest permission localization boundary (oa-wmvc) and ChatScrollRestorationTest scroll restoration guard (oa-wxf2). The four oa-3yk2 ChatViewModel red failures now pass. diff --git a/.tickets/oa-3zxu.md b/.tickets/oa-3zxu.md new file mode 100644 index 00000000..9996a546 --- /dev/null +++ b/.tickets/oa-3zxu.md @@ -0,0 +1,38 @@ +--- +id: oa-3zxu +status: closed +deps: [] +links: [] +created: 2026-05-10T09:42:11Z +type: chore +priority: 3 +assignee: Jasmin Le Roux +--- +# Delete unused OfishPermissionAutoApprover + +Problem: +OfishPermissionAutoApprover appears to be dead code. Keeping unused permission automation code around makes OFISH permission behavior harder to audit and can mislead future work into thinking background probes auto-approve permissions today. + +Evidence: +app/src/main/java/dev/blazelight/p4oc/data/files/ofish/OfishPermissionAutoApprover.kt defines the class, but repository search only finds the class declaration and no instantiation or dependency wiring in FileRepositoryFactory or OFISH modules. + +UX Constraint: +Permission behavior must stay explicit and human-readable. Do not introduce or imply silent permission approval for user-facing operations. + +Expected Behavior: +Remove the unused class and any now-unused tests/imports. If OFISH background probes later require permission handling, add a new ticket with concrete server behavior and UX constraints. + +Acceptance Criteria: +- Delete OfishPermissionAutoApprover.kt if still unused. +- Remove any stale imports/tests/docs that reference it. +- Confirm OFISH capability probe and mutation flows still compile. + +Verification: +Run ./gradlew :app:compileDebugKotlin. + + +## Notes + +**2026-05-10T11:24:23Z** + +Deleted unused OfishPermissionAutoApprover.kt and its only references in OfishPermissionAutoApproverTest.kt. Verified repository search finds no remaining Kotlin references to OfishPermissionAutoApprover, PermissionAutoApprovalResult, OfishPermissionResponder, or OfishWorkspacePermissionResponder. Attempted ./gradlew :app:compileDebugKotlin, but compile is currently blocked by unrelated SettingsScreen missing string resources from oa-r8yn (settings_help/status_legend_*). diff --git a/.tickets/oa-4olr.md b/.tickets/oa-4olr.md new file mode 100644 index 00000000..8d0f8a75 --- /dev/null +++ b/.tickets/oa-4olr.md @@ -0,0 +1,33 @@ +--- +id: oa-4olr +status: open +deps: [] +links: [oa-9ev3, oa-n1fs, oa-x9pe] +created: 2026-05-10T09:56:06Z +type: task +priority: 3 +assignee: Jasmin Le Roux +--- +# Evaluate removing custom terminal InputConnection wrapper + +Problem: +TermuxTerminalView wraps Termux TerminalView with custom KeyInterceptingContainer and TerminalInputView that implements InputConnection behavior and translates keys/text manually. If Termux TerminalView can handle software keyboard input directly, this wrapper adds OEM keyboard compatibility risk and maintenance cost. + +Evidence: +TermuxTerminalView.kt defines KeyInterceptingContainer and TerminalInputView. TerminalInputView overrides onCreateInputConnection(), commitText(), deleteSurroundingText(), and sendKeyEvent(), translating Android key events into terminal input. The underlying com.termux.view.TerminalView is already a terminal widget designed for keyboard input. + +UX Constraint: +Terminal input must work across Gboard, Samsung Keyboard, SwiftKey, hardware keyboards, IME composition, arrows/control keys, delete/backspace, paste, and special terminal escape sequences. Do not regress core terminal editing. + +Expected Behavior: +Use Termux TerminalView's native input handling where possible. Keep custom wrapper code only for documented Compose interop gaps or specific missing keys, with tests/manual matrix. + +Acceptance Criteria: +- Verify whether TerminalView can own focus and IME input directly inside AndroidView Compose interop. +- If native input works, delete or reduce KeyInterceptingContainer/TerminalInputView. +- If custom input remains necessary, document exactly why and add focused handling tests/manual verification notes. +- Preserve terminal resize/focus behavior and content descriptions/test tags. + +Verification: +Run ./gradlew :app:compileDebugKotlin and manually test terminal input on software and hardware keyboard scenarios. + diff --git a/.tickets/oa-5c1e.md b/.tickets/oa-5c1e.md new file mode 100644 index 00000000..9b2649f3 --- /dev/null +++ b/.tickets/oa-5c1e.md @@ -0,0 +1,41 @@ +--- +id: oa-5c1e +status: closed +deps: [] +links: [] +created: 2026-05-10T09:43:52Z +type: task +priority: 2 +assignee: Jasmin Le Roux +--- +# Wire static dead-code analysis into Gradle checks + +Problem: +We want to aggressively delete dead code, but the repo currently lacks an executable static-analysis gate for unused Kotlin declarations. There is a detekt.yml with UnusedPrivateMember and UnusedPrivateClass enabled, but Gradle does not apply the Detekt plugin, so no detekt task is available. + +Evidence: +detekt.yml enables style.UnusedPrivateMember, style.UnusedPrivateClass, formatting.NoUnusedImports, and potential-bugs.UnreachableCode. ./gradlew tasks --all shows lint/check tasks but no detekt task. build.gradle.kts and app/build.gradle.kts do not apply a Detekt plugin. + +UX Constraint: +Dead-code cleanup should be safe and boring. Static analysis findings must not encourage deleting code used by Android framework entry points, Compose previews, serialization, reflection, or resources without verification. + +Expected Behavior: +A developer can run one Gradle task to report unused private Kotlin code and related unreachable/no-unused-import findings. CI/checks can include the task once the baseline is clean or explicitly baselined. + +Acceptance Criteria: +- Add Detekt to the Gradle build or otherwise wire the existing detekt.yml into an executable Gradle task. +- Ensure the task analyzes app main and test Kotlin sources as appropriate. +- Keep existing ignore behavior for Preview/Composable annotations. +- Decide whether to fail immediately or introduce a baseline with documented cleanup follow-ups. +- Document the command in AGENTS.md or the project verification docs. +- Create cleanup tickets for confirmed dead-code findings rather than bulk-deleting ambiguous public/API/framework-referenced declarations. + +Verification: +Run the new detekt/static-analysis task locally and ./gradlew :app:compileDebugKotlin. + + +## Notes + +**2026-05-10T13:33:12Z** + +Implemented Detekt Gradle wiring for the app module using the existing root detekt.yml, added detekt-formatting support for NoUnusedImports, configured the task to scan app main/test/androidTest Kotlin sources, and documented :app:detekt in AGENTS.md. Initial run produced existing findings, so app/detekt-baseline.xml was generated and wired in rather than bulk-deleting ambiguous code. Created follow-up oa-0p94 for confirmed unused-code baseline cleanup. Verification passed: export JAVA_HOME=/usr/lib/jvm/java-17-openjdk && ./gradlew :app:detekt; export JAVA_HOME=/usr/lib/jvm/java-17-openjdk && ./gradlew :app:compileDebugKotlin. diff --git a/.tickets/oa-62nd.md b/.tickets/oa-62nd.md new file mode 100644 index 00000000..8de492d2 --- /dev/null +++ b/.tickets/oa-62nd.md @@ -0,0 +1,42 @@ +--- +id: oa-62nd +status: closed +deps: [] +links: [] +created: 2026-07-05T18:04:23Z +type: bug +priority: 1 +assignee: Jasmin Le Roux +parent: oa-nwha +--- +# Move hardcoded UI display text to resource-backed boundaries + +Problem: +Audit found multiple display strings generated outside Android resource/UI formatting boundaries. This repeats the permission-title bug pattern and makes localization, consistency, and testing harder. + +Evidence: +Known permission case is tracked in oa-wmvc. Additional audited examples include NotificationHelper.kt notification channel/title/body strings, ChatViewModel.kt built-in command descriptions, SlashCommandsPopup.kt labels/metadata, TodoTracker.kt status/progress labels, SkillsScreen.kt and MCP/skills status text, SoraLanguageRegistry.kt language fallback labels such as plain text, and TabBar.kt route/tab titles. + +UX Constraint: +User-facing copy should be localizable and consistent. Functional status text must be meaningful and should not leak protocol/internal names unless intentionally shown as technical metadata. + +Expected Behavior: +Domain/protocol/data layers expose raw facts and stable identifiers. UI or notification-specific formatters choose resource-backed strings at the presentation boundary. Tests assert raw domain state separately from UI-rendered localized text. + +Acceptance Criteria: +- Inventory audited hardcoded display strings and separate true user-facing strings from protocol/debug/internal identifiers. +- Move user-facing strings into resources or centralized UI formatter functions. +- Keep domain/data models free of localized/display titles except where the type is explicitly presentation-only. +- Update tests that currently assert English text below the UI/resource boundary. +- Ensure notification channel/title/body strings remain human-readable and resource-backed. +- Preserve technical identifiers where they are intentionally shown to users, with labels explaining context. + +Verification: +Run targeted unit tests for formatters and affected UI logic. Run resource/build compile after implementation. Detekt should not gain new hardcoded-string or unused-resource findings. + + +## Notes + +**2026-07-05T18:05:35Z** + +Superseded by narrower UI-surface display-boundary tickets to be created under oa-nwha. A single broad display-boundary ticket is too vague because notifications, todo labels, MCP/skills status, file language fallback, and tab/slash titles have separate user-facing behavior and verification. diff --git a/.tickets/oa-6d53.md b/.tickets/oa-6d53.md new file mode 100644 index 00000000..07ebe3f0 --- /dev/null +++ b/.tickets/oa-6d53.md @@ -0,0 +1,20 @@ +--- +id: oa-6d53 +status: closed +deps: [oa-qr52, oa-blgp] +links: [] +created: 2026-05-01T17:45:54Z +type: task +priority: 1 +assignee: Jasmin Le Roux +parent: oa-gt0g +tags: [workspace, nav] +--- +# Commit 2: WorkspaceViewModel + per-tab nav-graph scoping + +Add ui/workspace/WorkspaceViewModel.kt as scope owner per tab. Wrap each tab in a workspace/{tabId} nav graph in TabNavHost. Wire Koin factory. WorkspaceViewModel.onCleared() = teardown. Closing a tab → nav graph entry pops → onCleared fires → WorkspaceClient/SessionRepository disposed. ServerViewModel.disconnect tears down all tab scopes. + +## Acceptance Criteria + +1) WorkspaceViewModel is scoped to nav back-stack entry, not app singleton. 2) Test/manual: navigating tab A → tab B → back to tab A returns the SAME WorkspaceViewModel instance. 3) Closing a tab triggers WorkspaceViewModel.onCleared (verify with instrumented log). 4) Disconnect tears down all WorkspaceViewModels. 5) No app-global mutable currentWorkspace exposed. 6) ./gradlew :app:compileDebugKotlin green. + diff --git a/.tickets/oa-6s5v.md b/.tickets/oa-6s5v.md new file mode 100644 index 00000000..af390c7e --- /dev/null +++ b/.tickets/oa-6s5v.md @@ -0,0 +1,39 @@ +--- +id: oa-6s5v +status: closed +deps: [] +links: [] +created: 2026-05-10T09:55:29Z +type: bug +priority: 1 +assignee: Jasmin Le Roux +--- +# Dispatch chat run mutations outside ChatViewModel scope + +Problem: +ChatViewModel launches sendMessageAsync and abortSession calls in viewModelScope. If the user navigates away while the HTTP request is in flight, coroutine cancellation can abort the client request even though the server may already have started or needs the stop request delivered. + +Evidence: +ChatViewModel.sendMessage paths call safeApiCall { workspaceClient.sendMessageAsync(sessionId, request) } inside viewModelScope.launch. abort/stop paths are also invoked from ChatViewModel scope. WorkspaceClient.sendMessageAsync is a Retrofit suspend call that waits for HTTP completion. + +UX Constraint: +Starting or stopping an agent run should be tied to session/workspace intent, not transient chat screen lifetime. Navigating back should not orphan server work or cancel a requested stop silently. + +Expected Behavior: +Run-triggering mutations are dispatched through SessionRepository or a workspace/app-scoped command queue that survives chat screen teardown until request acknowledgement/failure. + +Acceptance Criteria: +- Move sendMessageAsync and abortSession dispatch ownership out of ChatViewModel viewModelScope, or explicitly shield critical network acknowledgement from UI cancellation. +- Preserve per-session busy/sending state and human-readable error presentation. +- Ensure closing the tab/workspace/disconnect cancels or reconciles pending commands intentionally. +- Add tests for navigation/cancellation during in-flight send/abort where feasible. + +Verification: +Run ChatViewModel/SessionRepository tests and ./gradlew :app:compileDebugKotlin. Manually send a prompt and navigate away immediately; returning should show a consistent run state. + + +## Notes + +**2026-05-10T10:29:22Z** + +Verified existing in-progress implementation: sendMessageAsync and abortSession are now dispatched by SessionRepositoryImpl in repository scope and ChatViewModel only awaits the returned Deferred for UI state/error updates. Repository tests cover cancelling a UI waiter while send/abort remains in flight; fake repository/client and ChatViewModel tests were updated accordingly. Verification: ./gradlew :app:testDebugUnitTest --tests dev.blazelight.p4oc.data.session.SessionRepositoryImplTest --tests dev.blazelight.p4oc.ui.screens.chat.ChatViewModelTest; export JAVA_HOME=/usr/lib/jvm/java-17-openjdk && ./gradlew :app:compileDebugKotlin. Manual send-and-navigate-away smoke test remains recommended. diff --git a/.tickets/oa-6zta.md b/.tickets/oa-6zta.md new file mode 100644 index 00000000..58a711f4 --- /dev/null +++ b/.tickets/oa-6zta.md @@ -0,0 +1,44 @@ +--- +id: oa-6zta +status: closed +deps: [oa-rde5, oa-aihi, oa-d55a, oa-82uy] +links: [] +created: 2026-05-01T17:45:54Z +type: task +priority: 1 +assignee: Jasmin Le Roux +parent: oa-gt0g +tags: [workspace, demolition] +--- +# Commit 7+8 merged: STRIP ConnectionManager wiring + DELETE DirectoryManager + dead DataStore keys + +Demolition gate. Delete: ConnectionManager directoryProvider wiring (line 110) + setOnDirectoryChangedListener block (line 132). Delete: OpenCodeEventSource directoryProvider param. DELETE core/network/DirectoryManager.kt entirely. Delete from SettingsDataStore: KEY_PROJECT_WORKTREE, KEY_LAST_SESSION_ID, lastSessionId flow, get/setProjectWorktree, get/setLastSessionId. Add DataStore migration so existing users don't crash on first open with old prefs. Delete the two singleton bindings in KoinModules.kt. Tree compiles green here. + +## Acceptance Criteria + +1) DirectoryManager.kt does NOT exist. 2) SessionDataCache.kt does NOT exist (already deleted in T12). 3) MessageStore.kt does NOT exist (already deleted in T13). 4) ConnectionManager has no DirectoryManager param. 5) OpenCodeEventSource has no directoryProvider param. 6) project_worktree, last_session_id keys NOT in SettingsDataStore. 7) DataStore migration exists; planted user with old prefs launches without crash. 8) ./gradlew :app:compileDebugKotlin GREEN. 9) ./gradlew :app:assembleDebug produces APK that launches. + + +## Notes + +**2026-05-02T13:14:05Z** + +Implemented demolition pass. + +Summary: +- Deleted core/network/DirectoryManager.kt. +- Removed DirectoryManager from ConnectionManager constructor and Koin DI. +- Removed ConnectionManager directoryProvider wiring and setOnDirectoryChangedListener reconnect block. +- Removed OpenCodeEventSource directoryProvider constructor parameter. +- Removed SettingsDataStore KEY_PROJECT_WORKTREE / KEY_LAST_SESSION_ID declarations, lastSessionId flow, setLastSessionId, projectWorktree flow, getProjectWorktree, and setProjectWorktree. +- Added DataStore migration that removes old project_worktree and last_session_id persisted keys for existing users. + +Verification: +- export JAVA_HOME=/usr/lib/jvm/java-17-openjdk && ./gradlew :app:compileDebugKotlin: BUILD SUCCESSFUL +- export JAVA_HOME=/usr/lib/jvm/java-17-openjdk && ./gradlew :app:testDebugUnitTest: BUILD SUCCESSFUL +- export JAVA_HOME=/usr/lib/jvm/java-17-openjdk && ./gradlew :app:assembleDebug: BUILD SUCCESSFUL + +Greps: +- DirectoryManager, directoryProvider, setOnDirectoryChangedListener, KEY_PROJECT_WORKTREE, KEY_LAST_SESSION_ID, lastSessionId/getProjectWorktree/setProjectWorktree APIs are gone from app source. +- Remaining literal last_session_id/project_worktree references are only inside the migration that deletes old prefs. +- SessionDataCache.kt and MessageStore.kt remain deleted. diff --git a/.tickets/oa-764s.md b/.tickets/oa-764s.md new file mode 100644 index 00000000..2de948bb --- /dev/null +++ b/.tickets/oa-764s.md @@ -0,0 +1,57 @@ +--- +id: oa-764s +status: closed +deps: [] +links: [oa-a6l7, oa-1n6h, oa-t4t2, oa-p7ei, oa-es45] +created: 2026-04-19T13:57:29Z +type: chore +priority: 3 +assignee: Jasmin Le Roux +external-ref: pr-3-cherrypick +tags: [ci, build] +--- +# CI: detekt + lintDebug gates, debug APK artifact upload + +Port CI hardening from PR #3 commit e01b157. + +## Changes to .github/workflows/build.yml + +Add before compile step: +```yaml +- name: Static analysis (Detekt) + run: ./gradlew detekt + continue-on-error: true # until we clean up existing violations + +- name: Lint + run: ./gradlew :app:lintDebug +``` + +Add after build step: +```yaml +- name: Upload debug APK(s) + uses: actions/upload-artifact@v4 + with: + name: debug-apk + path: app/build/outputs/apk/debug/*.apk + if-no-files-found: warn +``` + +Clean up release step: replace explicit rename-and-upload with glob pattern. + +## Also + +- Port detekt.yml config changes from pr-3 (7-line diff, threshold tweaks) + +## Do NOT include + +- ABI splits (pointless without native code, which we rejected) +- network_security_config.xml changes (hardened release config — revisit separately if/when we care) +- Anything in :macrobenchmark CI (PR E handles that separately) + +## Acceptance Criteria + +1. CI green on PR +2. detekt runs (non-blocking initially) +3. lintDebug blocks merge if violations +4. Debug APK downloadable from Actions artifacts + diff --git a/.tickets/oa-7uuf.md b/.tickets/oa-7uuf.md new file mode 100644 index 00000000..b1105ab8 --- /dev/null +++ b/.tickets/oa-7uuf.md @@ -0,0 +1,39 @@ +--- +id: oa-7uuf +status: closed +deps: [] +links: [] +created: 2026-05-10T09:55:57Z +type: chore +priority: 3 +assignee: Jasmin Le Roux +--- +# Simplify mDNS service resolution with coroutine Mutex + +Problem: +MdnsDiscoveryManager manually serializes Android NSD resolve calls using ConcurrentLinkedQueue, isResolving flags, and callback-driven queue polling. This is custom state-machine code for a one-at-a-time constraint that Kotlin coroutines can express more simply. + +Evidence: +MdnsDiscoveryManager has resolveQueue = ConcurrentLinkedQueue(), @Volatile isResolving, enqueueResolve(), startNextResolveLocked/resolve callbacks, and a separate Semaphore for seed HTTP probes. Android NSD only allows one resolve at a time. + +UX Constraint: +mDNS discovery should remain reliable without duplicate or stuck resolves. Discovery failures should remain logged/human-readable and not block manual server entry. + +Expected Behavior: +Use structured concurrency, such as Mutex.withLock around a suspend resolveService wrapper or a single-worker Channel, instead of manual queue/boolean state. + +Acceptance Criteria: +- Replace manual resolveQueue/isResolving state with Mutex or Channel worker. +- Preserve service found/lost behavior and cancellation on stop. +- Preserve probe concurrency limits where useful. +- Add tests or manual verification for multiple discovered services in quick succession. + +Verification: +Run ./gradlew :app:compileDebugKotlin and manually test mDNS discovery where possible. + + +## Notes + +**2026-05-10T11:55:26Z** + +Replaced MdnsDiscoveryManager manual resolveQueue/isResolving state machine with per-discovery resolve jobs serialized by a coroutine Mutex. NSD resolve is wrapped in suspendCancellableCoroutine; stopDiscovery cancels the resolve parent job; service found/lost behavior and seed probe Semaphore concurrency are preserved. Verification: JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew :app:compileDebugKotlin passed. Manual mDNS discovery test not run in this CLI session. diff --git a/.tickets/oa-7wc7.md b/.tickets/oa-7wc7.md new file mode 100644 index 00000000..3a1dadbc --- /dev/null +++ b/.tickets/oa-7wc7.md @@ -0,0 +1,26 @@ +--- +id: oa-7wc7 +status: closed +deps: [] +links: [] +created: 2026-05-05T17:48:39Z +type: chore +priority: 2 +assignee: Jasmin Le Roux +parent: oa-ssm2 +tags: [cleanup, phase-0] +--- +# Cleanup: delete 7 orphan UI components and dead modal-permission state + +Delete the seven orphan Compose components flagged by the cleanup-review council that have zero callers in main: PartVisualizations.kt (drop StepStartDisplay only — keep RetryPartDisplay/StepFinishDisplay for later wiring), MultiAgentRuns.kt (full delete; AgentRunStatus has no server source), ContextUsageDisplay.kt (defer to a future ticket; delete or relocate), MessageBranching.kt (keep ForkMessageButton + simple count badge, delete the carousel/active-branch UI that invents server semantics), ErrorBoundary.kt (Compose has no error boundary semantics — actively misleading), FileAttachment.kt (full delete now; ~80 LOC of MIME helpers will be re-introduced under ui/screens/files/upload/ in Phase 7), PermissionDialogEnhanced.kt (inline prompt is canonical). Also strip dead modal-permission state in DialogQueueManager.kt: pendingPermissions queue, _pendingPermission flow, KEY_PENDING_PERMISSION* keys, persistPermissionsQueue, showNextPermission, modal branch of clearPermission. ~70 extra LOC. + +## Acceptance Criteria + +Files deleted. App compiles. ChatScreen still renders correctly with InlinePermissionPrompt. No grep hits for deleted symbols. Council pre-approved. Reference: file-ops-signoff.html section 6. + + +## Notes + +**2026-05-05T18:41:38Z** + +Implemented. Verified zero callers outside file for all 7 components (rg log in implementer report). Deleted: PartVisualizations.kt (338), MultiAgentRuns.kt (271), ContextUsageDisplay.kt (351), MessageBranching.kt (455), ErrorBoundary.kt (380), FileAttachment.kt (302), PermissionDialogEnhanced.kt (491). Trimmed dead modal-permission state from DialogQueueManager.kt: removed pendingPermissions queue, _pendingPermission StateFlow, KEY_PENDING_PERMISSION* keys, persistPermissionsQueue(), showNextPermission(), modal branches of clearPermission/clearPermissionByRequestId. Inline pendingPermissionsByCallId path (used by ChatMessage.kt:178-186) fully preserved. Net deletion: 2,652 LOC. Build green. diff --git a/.tickets/oa-7ysx.md b/.tickets/oa-7ysx.md new file mode 100644 index 00000000..c939d480 --- /dev/null +++ b/.tickets/oa-7ysx.md @@ -0,0 +1,26 @@ +--- +id: oa-7ysx +status: closed +deps: [] +links: [] +created: 2026-05-01T17:44:25Z +type: task +priority: 1 +assignee: Jasmin Le Roux +parent: oa-gt0g +tags: [design, workspace] +--- +# Design lock A: deep link migration + permission/question modality + +Decide: (a) what happens to old chat/{sessionId}?directory={directory} deep links — explicit error screen vs silent drop; (b) permission/question dialogs — per-tab modal or app-modal? If global event fires permission for workspace A while tab B is active, where does prompt appear? Document with concrete examples. + +## Acceptance Criteria + +1) Decision document written (in openspec/ or docs/). 2) Each open question has ONE chosen behavior + ONE rejected alternative documented. 3) Concrete worked examples: old deep link, perm event for background tab, perm event after owning tab closed, server switch mid-prompt. 4) Names exact files affected. 5) No 'TBD' or 'follow-up' for cutover-critical items. + + +## Notes + +**2026-05-01T18:19:06Z** + +Decision locked. See docs/design-locks/A-deep-links-and-prompts.md diff --git a/.tickets/oa-82uy.md b/.tickets/oa-82uy.md new file mode 100644 index 00000000..7c618a2e --- /dev/null +++ b/.tickets/oa-82uy.md @@ -0,0 +1,44 @@ +--- +id: oa-82uy +status: closed +deps: [oa-6d53, oa-blgp] +links: [] +created: 2026-05-01T17:45:54Z +type: task +priority: 1 +assignee: Jasmin Le Roux +parent: oa-gt0g +tags: [workspace, files, diff] +--- +# Commit 6b: Rewrite SessionDiffScreen + FilesViewModel + FileExplorerScreen (path/URI rendering) + +SessionDiffScreen receives WorkspaceSession from nav (no DirectoryManager injection). FilesViewModel uses Workspace from per-tab scope. FileExplorerScreen symbol URI parsing at line 219 → WorkspacePath.parseFromServer(uri). Breadcrumb stops mixing '' / '.' / '/abs' — single canonical representation per design-E. + +## Acceptance Criteria + +1) SessionDiffScreen has no DirectoryManager. 2) Diff opens correctly from session list AND from chat — same workspace. 3) Symbol URI with %20, ?, # parses correctly via WorkspacePath.parseFromServer (round-trip test passes). 4) File viewer handles paths with spaces/unicode. 5) FilesViewModel has no DirectoryManager. 6) Manual smoke: browse files in two workspaces in two tabs without cross-contamination. + + +## Notes + +**2026-05-02T12:55:52Z** + +Implemented files/diff workspace rewrite. + +Summary: +- FilesViewModel now takes WorkspaceClient and no longer uses ConnectionManager/global API access. +- FileExplorerScreen/FileViewerScreen require an explicitly supplied FilesViewModel; Koin binding for FilesViewModel was removed. +- TabNavHost wires FileExplorerScreen/FileViewerScreen from the per-tab WorkspaceViewModel workspaceClient. +- SessionDiffScreen now takes WorkspaceClient, has no DirectoryManager/ConnectionManager injection, and fetches diff through workspaceClient.getSessionDiff(...). +- WorkspaceClient gained workspace-scoped file/diff helpers. +- Symbol URI parsing now goes through WorkspacePathParser.parseFromServer(...), preserving encoded spaces/unicode and encoded/raw ?/# path characters. +- Breadcrumb navigation now uses one canonical relative representation: root is "" and child paths are relative without leading /. + +Verification: +- export JAVA_HOME=/usr/lib/jvm/java-17-openjdk && ./gradlew :app:compileDebugKotlin: BUILD SUCCESSFUL +- export JAVA_HOME=/usr/lib/jvm/java-17-openjdk && ./gradlew :app:testDebugUnitTest --tests 'dev.blazelight.p4oc.domain.workspace.*': BUILD SUCCESSFUL +- export JAVA_HOME=/usr/lib/jvm/java-17-openjdk && ./gradlew :app:testDebugUnitTest: BUILD SUCCESSFUL + +Greps: +- No DirectoryManager/ConnectionManager/getApi/file:// usage remains under ui/screens/files. +- SessionDiffScreen has no DirectoryManager/ConnectionManager/getApi usage. diff --git a/.tickets/oa-8dfl.md b/.tickets/oa-8dfl.md new file mode 100644 index 00000000..137b71f0 --- /dev/null +++ b/.tickets/oa-8dfl.md @@ -0,0 +1,39 @@ +--- +id: oa-8dfl +status: closed +deps: [] +links: [] +created: 2026-05-10T09:55:50Z +type: task +priority: 2 +assignee: Jasmin Le Roux +--- +# Remove pseudo file URI attachment path codec + +Problem: +Chat file attachments wrap workspace-relative paths in a pseudo file:/// URI and URL-encode path segments before sending them in JSON. This adds URI parsing/encoding failure modes for data that can be represented as raw JSON strings. + +Evidence: +WorkspacePathAttachmentCodec.kt defines WorkspacePath.Relative.toAttachmentUrl() as file:/// plus encoded path segments and parseFromServer() to decode file:// values. ChatViewModel uses WorkspacePath.Relative(RelativePath(file.path)).toAttachmentUrl() for attachment url. FileExplorerScreen parses symbol.uri through WorkspacePathAttachmentCodec.parseFromServer(). + +UX Constraint: +Workspace file paths with spaces, unicode, punctuation, or platform-specific characters should round-trip without brittle URI semantics. Do not leak workspace directories or use navigation-route encoding rules for API payloads. + +Expected Behavior: +Attachment DTOs use raw workspace-relative path strings unless the OpenCode backend explicitly requires URI-formatted attachment URLs. Any server-required format should be isolated and documented. + +Acceptance Criteria: +- Confirm OpenCode API expected attachment url/path format. +- If raw strings are accepted, delete WorkspacePathAttachmentCodec and send raw relative paths in JSON bodies. +- Update server symbol/file click parsing to avoid unnecessary file:// decoding where possible. +- Add tests for paths with spaces, percent signs, unicode, query/hash-like characters, and slashes. + +Verification: +Run mapper/chat/file tests and ./gradlew :app:compileDebugKotlin. Manually attach/open files with special characters in names. + + +## Notes + +**2026-05-10T12:25:27Z** + +Confirmed OpenCode FilePartInput.url examples and prompt processor require file:/file:// or data: URLs, not raw relative paths. Removed shared WorkspacePathAttachmentCodec; chat now builds the backend-required file URL locally from the scoped workspace directory, and symbol results expose decoded relative paths separately. Verified with targeted unit tests and :app:compileDebugKotlin. diff --git a/.tickets/oa-8qyw.md b/.tickets/oa-8qyw.md new file mode 100644 index 00000000..c40480d0 --- /dev/null +++ b/.tickets/oa-8qyw.md @@ -0,0 +1,32 @@ +--- +id: oa-8qyw +status: closed +deps: [oa-v3js] +links: [] +created: 2026-05-05T18:19:53Z +type: chore +priority: 2 +assignee: Jasmin Le Roux +parent: oa-ssm2 +tags: [files, sora, cleanup] +--- +# Delete SyntaxHighlighter.kt; replace with Sora TextMate tokenizer wrapper + +Once SoraEditor lands (depends on the editor ticket), delete the 546-line custom regex syntax highlighter at ui/components/code/SyntaxHighlighter.kt. Replace its read-only AnnotatedString output (used for chat code fences via mikepenz markdown renderer's code-block slot, and for FileViewerScreen view mode) with a thin wrapper around Sora's TextMate tokenizer — TextMateLanguage.create(...) with analyzeManager headless mode, converting tokens to AnnotatedString. + +Net change: -546 LOC + ~120 LOC = -426 LOC. Plus correctness gains for languages the regex highlighter never properly handled (TypeScript, Rust, Go, Ruby, PHP, SQL — all listed at SyntaxHighlighter.kt:31-55). Delete , , from that file; the new wrapper lives at ui/components/code/TextMateAnnotatedString.kt. + +## Acceptance Criteria + +SyntaxHighlighter.kt deleted. New TextMateAnnotatedString.kt < 130 LOC. Chat code fences still render with highlighting. FileViewerScreen view mode still highlights. Tests on at least Kotlin, Python, TypeScript, Rust verify token correctness. + + +## Notes + +**2026-05-05T18:20:03Z** + +Correction: the three eaten identifiers in the description are 'SyntaxColors', 'Language', and 'OpenCodeSyntaxHighlighter' (the public symbols in SyntaxHighlighter.kt that need deleting along with the file). zsh ate them due to backtick interpretation in the original create command. + +**2026-05-07T14:48:05Z** + +Pausing while corrective batch lands: WorkspaceFileRepository drops FileContentDto.hash, OfishBaselineHasher hashes in-memory string instead of on-disk bytes, GPL Termux relinking notice missing, MIT grammar entries marked version=null. Council audit confirmed reward hacks. Resuming after fix batch with view/edit highlighter unification. diff --git a/.tickets/oa-9ev3.md b/.tickets/oa-9ev3.md new file mode 100644 index 00000000..c3b66f48 --- /dev/null +++ b/.tickets/oa-9ev3.md @@ -0,0 +1,68 @@ +--- +id: oa-9ev3 +status: open +deps: [] +links: [oa-4olr, oa-x9pe, oa-12ui] +created: 2026-07-05T18:06:47Z +type: bug +priority: 1 +assignee: Jasmin Le Roux +parent: oa-nwha +--- +# Require explicit terminal PTY shell cwd and title defaults + +Problem: +Terminal PTY creation uses Android-guessed defaults such as /bin/bash, cwd '.', and title Terminal. These defaults can launch the wrong shell or directory and hide missing workspace context. + +Evidence: +Hardcoded-default audit identified PtyDtos.kt CreatePtyRequest defaults: shell/command defaults to /bin/bash, cwd defaults to '.', and title defaults to Terminal. + +UX Constraint: +Terminal workflows are core workspace operations. Starting a terminal in the wrong directory or shell can cause destructive wrong-project commands. Defaults must be explicit, workspace-scoped, and human-readable when unavailable. + +Expected Behavior: +PTY requests use the selected workspace directory and server/upstream/user-configured shell/title policy. Missing cwd/shell context is surfaced or intentionally server-delegated; Android should not silently invent a global cwd or shell. + +Acceptance Criteria: +- Remove or replace DTO-level /bin/bash, '.', and Terminal defaults with explicit request construction policy. +- Determine source of truth for shell, cwd, and title: server default, workspace directory, user setting, or explicit UI selection. +- Ensure terminal creation requires or derives workspace-scoped cwd intentionally. +- Add tests proving missing workspace/cwd does not silently become '.'. +- Add tests proving shell/title defaults come from the chosen source of truth or are omitted for server defaulting. +- Provide human-readable error/setup UI if a terminal cannot be created due to missing context. + +Verification: +Run targeted terminal PTY request tests and compile. Smoke test opening a terminal from a workspace tab. + + +## Notes + +**2026-07-06T08:54:42Z** + +Clarification from 2026-07-05 workspace/tab UX discussion: + +Coordinate this ticket with oa-e6g3. Top-level terminal creation from MainTabScreen.kt:394-396 currently inherits activeWorkspaceDirectory; the agreed flat-tab model says top-level menu/tab creation must not implicitly inherit the active tab's workspace. For this ticket, that means PTY request construction must distinguish: + +1. Top-level terminal creation: use an explicit server/global default policy OR ask the user to choose workspace/server context. Do not use the active tab's directory by omission. +2. Contextual terminal creation from a specific tab/project, e.g. MainTabScreen.kt:498-500: preserve that tab's explicit WorkspaceKey.Directory when constructing cwd/title/request data. +3. Legacy/missing workspace: do not silently map missing cwd to '.'. Surface a human-readable recovery/setup state or intentionally omit cwd for server defaulting if the backend contract supports that. + +The existing ticket wording about 'selected workspace directory' should be read as 'explicit terminal context', not 'always require Directory'. WorkspaceKey.Global may be valid for top-level/server-default terminal behavior if product chooses that policy. + +**2026-07-06T08:55:20Z** + +Concrete PTY callsite clarification from 2026-07-05 discussion: + +MainTabScreen.kt currently creates PTYs with zero-arg CreatePtyRequest() at both top-level/contextual paths: +- around MainTabScreen.kt:390 before creating the top-level terminal tab +- around MainTabScreen.kt:494 before creating a terminal from a specific tab/context + +Because CreatePtyRequest() has DTO defaults, both paths currently use /bin/bash, cwd '.', and title 'Terminal'. Neither path passes the workspace directory as cwd today. + +Required policy with oa-e6g3 WorkspaceKey model: +- WorkspaceKey.Directory terminals: construct CreatePtyRequest explicitly with cwd = directory path (or the product-approved project terminal cwd), not DTO default '.'. This applies to contextual terminal creation from a project/workspace tab. +- WorkspaceKey.Global terminals: there is no workspace directory to use as cwd. Use a verified server-delegated cwd policy or ask the user to choose cwd/context. Do not use '.' as an Android-guessed fallback unless the backend contract explicitly defines it and tests assert that behavior. +- Top-level terminal creation from menu must not implicitly inherit active tab directory; it must choose the Global/server-delegated/user-chosen policy explicitly. + +Acceptance addendum: +Tests should cover both MainTabScreen PTY construction paths or their extracted request builder: top-level Global does not send '.', and Directory contextual creation sends the directory cwd explicitly. diff --git a/.tickets/oa-9o9t.md b/.tickets/oa-9o9t.md new file mode 100644 index 00000000..31b1be2a --- /dev/null +++ b/.tickets/oa-9o9t.md @@ -0,0 +1,39 @@ +--- +id: oa-9o9t +status: closed +deps: [] +links: [] +created: 2026-05-10T09:50:29Z +type: bug +priority: 1 +assignee: Jasmin Le Roux +--- +# Use lifecycle-managed ViewModels in TabNavHost + +Problem: +TabNavHost manually constructs Android ViewModel subclasses inside remember blocks. Manually created ViewModels are not attached to a ViewModelStore, so onCleared() is not called and viewModelScope jobs can leak. + +Evidence: +TabNavHost creates SessionListViewModel(workspaceViewModel.sessionRepository) inside remember(workspaceViewModel) for Sessions and SessionsFiltered routes. It creates FilesViewModel(FileRepositoryFactory.create(...)) inside remember(workspaceViewModel) for Files and FileViewer routes. These classes extend ViewModel and launch viewModelScope coroutines. + +UX Constraint: +Navigating between routes/tabs must not leak collectors, duplicate work, or keep stale screen state alive. ViewModel lifetime should be explicit and lifecycle-managed. + +Expected Behavior: +SessionListViewModel and FilesViewModel are provided through Koin/ViewModelProvider with parameters, or converted to non-ViewModel state holders if manually remembered. + +Acceptance Criteria: +- Remove manual construction of ViewModel subclasses from remember blocks in TabNavHost. +- Provide lifecycle-managed Koin viewModel definitions/factories for parameterized SessionListViewModel and FilesViewModel, or refactor them out of Android ViewModel inheritance. +- Ensure parameter scoping includes workspace/session repository identity and route-specific filters where needed. +- Verify onCleared() runs when the owning route/tab is actually destroyed. + +Verification: +Run ./gradlew :app:compileDebugKotlin and navigate repeatedly between Sessions/Files/FileViewer while checking logs or tests for no duplicate collectors. + + +## Notes + +**2026-05-10T10:32:45Z** + +Removed manual remember-based construction of SessionListViewModel and FilesViewModel from TabNavHost. Added parameterized Koin ViewModel definitions for SessionListViewModel(SessionRepositoryImpl) and FilesViewModel(FileRepository), and resolve them with each route NavBackStackEntry as ViewModelStoreOwner plus workspace/route-aware keys. Route/tab teardown now goes through ViewModelProvider so onCleared/viewModelScope cleanup is lifecycle-managed. Verification: export JAVA_HOME=/usr/lib/jvm/java-17-openjdk && ./gradlew :app:compileDebugKotlin. Manual repeated Sessions/Files/FileViewer navigation smoke test still recommended. diff --git a/.tickets/oa-a6l7.md b/.tickets/oa-a6l7.md new file mode 100644 index 00000000..5c2c1520 --- /dev/null +++ b/.tickets/oa-a6l7.md @@ -0,0 +1,51 @@ +--- +id: oa-a6l7 +status: closed +deps: [] +links: [oa-1n6h, oa-t4t2, oa-764s, oa-p7ei, oa-es45] +created: 2026-04-19T13:57:42Z +type: feature +priority: 2 +assignee: Jasmin Le Roux +external-ref: pr-3-cherrypick +tags: [discovery, networking, mdns] +--- +# Seeded mDNS discovery from recent + typed URLs (Tailscale/VPN UX) + +mDNS can't reach servers over Tailscale/VPN (no multicast). The PR #3 sweep approach is too aggressive (512 targets × 32 concurrent probes on every network). Instead: seed discovery from URLs the user has already shown interest in. + +## Design + +Add MdnsDiscoveryManager.startDiscovery(seeds: List) variant: +- Keep existing mDNS/NSD scanning for Wi-Fi _http._tcp _opencode-*_ services +- For each seed URL: resolve hostname, health-probe the resolved address, add to discovered list on 200 response +- Bounded concurrency (4 at a time), short timeout (2s per probe) + +ServerViewModel.startDiscovery passes recentServers.map { it.url } + currentTypedUrl as seeds. + +## Explicitly NOT doing + +- Subnet sweep (512 targets, CIDR enumeration, reverse DNS) — too aggressive, battery/privacy concern on corporate networks +- Accepting 401/403/404 + header sniffing as positive hits — false-positive soup +- Multicast lock acquisition (not needed without sweep) +- ACCESS_WIFI_STATE / CHANGE_WIFI_MULTICAST_STATE permissions + +## Verify existing lifecycle + +Main currently uses DisposableEffect start/stop in ServerScreen — verify this is not regressed. PR #3 broke it (LaunchedEffect without stop on dispose). + +## Test plan + +- Tailscale server on 100.x.y.z visited once, added to recent +- Next discovery run: 100.x server should appear in discovered list +- Local mDNS server should still appear via NSD path +- Nothing else should appear (no sweep, no false positives) + +## Acceptance Criteria + +1. Recent servers show up in discovered list when reachable +2. mDNS still works on local Wi-Fi +3. No new permissions needed +4. Discovery stops when leaving server screen +5. No regression on PR #1 self-signed TLS flow + diff --git a/.tickets/oa-acdb.md b/.tickets/oa-acdb.md new file mode 100644 index 00000000..06dfe429 --- /dev/null +++ b/.tickets/oa-acdb.md @@ -0,0 +1,39 @@ +--- +id: oa-acdb +status: closed +deps: [oa-s5jj] +links: [] +created: 2026-05-05T17:57:03Z +type: feature +priority: 2 +assignee: Jasmin Le Roux +parent: oa-ssm2 +tags: [files, upload, saf, phase-7] +--- +# Tier C: SAF upload via OFISH chunked write + +Add an 'Upload here' action in FileExplorerScreen top bar (next to Refresh at FileExplorerScreen.kt:136-156). Uses Android SAF ActivityResultContracts.OpenMultipleDocuments to pick files. Default destination = current explorer path (uiState.currentPath). For v1, no destination picker — user navigates first. + +For each picked Uri: +- Read content via ContentResolver. +- Determine MIME via ContentResolver.getType then extension fallback. +- Pipe through FileRepository.upload (which uses OFISH chunked write at 256 KiB raw per chunk). +- Show progress sheet ('Uploading 2 of 5: logo.png — 47 KB / 120 KB'). +- On success, refresh the file list. + +Salvage from the deleted FileAttachment.kt (~80 LOC of pure helpers) into a NEW package ui/screens/files/upload/UploadVisuals.kt: getFileSymbol, getMimeTypeLabel, formatFileSize, the chip/preview visuals. Rename data model to PendingUpload(uri, name, mimeType, size, targetPath, state). + +DO NOT ship: 'Share to opencode' Android intent target, multi-file destination picker, drag/drop. Defer to v1.5. + +This is strictly additive to chat attachments — chat keeps using path-based SelectedFile workspace files; SAF brings DEVICE files into the workspace, after which they can be attached via the existing chat picker normally. + +## Acceptance Criteria + +Upload button visible in FileExplorerScreen. Multi-pick works. Progress UI shows per-file. Failed chunk retries up to 3 times then surfaces error with 'partial preserved at .ofish.upload-.partial' affordance to discard. UploadVisuals.kt < 100 LOC. Existing chat attachment flow unchanged. + + +## Notes + +**2026-05-05T18:19:53Z** + +Per council directive 4: each upload uses an ephemeral session (create → probe → stream → verify → delete). DELETE /session/{id} confirmed at OpenCodeApi.kt:46-50. Concurrent uploads use different sessions. Crash/reconnect = restart from byte 0. Sweep orphan __ofish_* sessions on workspace connect. diff --git a/.tickets/oa-aihi.md b/.tickets/oa-aihi.md new file mode 100644 index 00000000..3cbc9438 --- /dev/null +++ b/.tickets/oa-aihi.md @@ -0,0 +1,44 @@ +--- +id: oa-aihi +status: closed +deps: [oa-rde5, oa-ja73, oa-7ysx, oa-ww0m] +links: [] +created: 2026-05-01T17:45:54Z +type: task +priority: 1 +assignee: Jasmin Le Roux +parent: oa-gt0g +tags: [workspace, chat] +--- +# Commit 4: Rewrite ChatViewModel against WorkspaceClient + SessionRepository, delete MessageStore + +ChatViewModel ctor takes WorkspaceClient + SessionRepository + WorkspaceSession from SavedStateHandle. Delete MessageStore field; observe SessionRepository.messages(WorkspaceSession). Delete loadSession side effect on DirectoryManager. Delete file:// literal at line 477 → AttachmentRef.File(WorkspacePath(...)). Delete getDirectory() helper. Permission/question responses derive workspace from session per design-A and design-F. DELETE MessageStore.kt. + +## Acceptance Criteria + +1) ChatViewModel has no DirectoryManager dependency. 2) loadSession does NOT call directoryManager.setDirectory. 3) MessageStore.kt does NOT exist. 4) No 'file://' string literal in ui/screens/chat/. 5) Manual smoke: open session, send 'hello', stream returns. 6) Manual smoke: attach file with relative path, server receives correct relative path (verify with logging). 7) Manual smoke: open child session — workspace preserved. 8) Existing MessageStoreTest behavior is REPLACED in SessionReducerTest. + + +## Notes + +**2026-05-02T12:08:52Z** + +Implemented ChatViewModel workspace rewrite. + +Summary: +- ChatViewModel now receives WorkspaceClient + SessionRepositoryImpl and no longer depends on DirectoryManager. +- Removed loadSession side effect that wrote to DirectoryManager. +- Moved message state/update behavior into SessionRepositoryImpl.messages(...), loadMessages(...), clearStreamingFlags(...), and acceptEvent(...). +- Deleted MessageStore.kt. +- Replaced MessageStoreTest with SessionRepositoryMessageStateTest. +- Removed file:// attachment literal in chat; attachments now use WorkspacePath.Relative(RelativePath(...)).toAttachmentUrl(). +- Chat API calls now go through WorkspaceClient for send, command, permissions/questions, todos, VCS, revert/unrevert, abort, and command listing. +- TabNavHost wires ChatViewModel using the per-tab WorkspaceViewModel workspaceClient/sessionRepository. + +Verification: +- export JAVA_HOME=/usr/lib/jvm/java-17-openjdk && ./gradlew :app:compileDebugKotlin: BUILD SUCCESSFUL +- export JAVA_HOME=/usr/lib/jvm/java-17-openjdk && ./gradlew :app:testDebugUnitTest --tests 'dev.blazelight.p4oc.ui.screens.chat.*' --tests 'dev.blazelight.p4oc.data.session.*': BUILD SUCCESSFUL +- export JAVA_HOME=/usr/lib/jvm/java-17-openjdk && ./gradlew :app:testDebugUnitTest: BUILD SUCCESSFUL + +Greps: +- No DirectoryManager/MessageStore/file:// usages remain under ui/screens/chat/. diff --git a/.tickets/oa-ap7c.md b/.tickets/oa-ap7c.md new file mode 100644 index 00000000..b0a79fe4 --- /dev/null +++ b/.tickets/oa-ap7c.md @@ -0,0 +1,15 @@ +--- +id: oa-ap7c +status: closed +deps: [oa-pecx] +links: [] +created: 2026-03-05T19:51:06Z +type: feature +priority: 2 +assignee: Jasmin Le Roux +tags: [ui, sessions] +--- +# Session rename via context menu + +Add rename option to session long-press context menu using PATCH /session/:id. Show TuiInputDialog pre-filled with current title. UpdateSessionRequest DTO and api.updateSession() already exist. + diff --git a/.tickets/oa-as4e.md b/.tickets/oa-as4e.md new file mode 100644 index 00000000..19e47265 --- /dev/null +++ b/.tickets/oa-as4e.md @@ -0,0 +1,41 @@ +--- +id: oa-as4e +status: closed +deps: [] +links: [] +created: 2026-05-10T09:54:51Z +type: task +priority: 1 +assignee: Jasmin Le Roux +--- +# Share SessionRepository by workspace instead of tab + +Problem: +SessionRepositoryImpl instances are currently tied to tab/workspace ViewModel lifetimes. Multiple tabs opened to the same server/workspace can create duplicate repositories, duplicate hydration work, and divergent in-memory snapshots for the same workspace. + +Evidence: +WorkspaceViewModel owns val sessionRepository = SessionRepositoryImpl(...) and is created per tab via TabNavHost/TouchWorkspaceViewModel parameters including tabId, workspace, and generation. Opening the same workspace in multiple tabs creates separate WorkspaceViewModel and SessionRepositoryImpl instances. + +UX Constraint: +Tabs showing the same workspace should agree on session/message state, deletes, streaming flags, and SSE updates without redundant network load. Closing one tab must not close the shared repository if another tab still uses it. + +Expected Behavior: +Session repositories are keyed by server/generation/workspace key, with tab-level consumers attaching to shared repository state. Repository lifetime is reference-counted or otherwise tied to active workspace consumers and disconnect/generation changes. + +Acceptance Criteria: +- Introduce a WorkspaceStore/SessionRepositoryProvider keyed by server, generation, and WorkspaceKey. +- Reuse one SessionRepositoryImpl for multiple tabs targeting the same workspace generation. +- Close a shared repository only when the last owning tab/workspace consumer is gone or generation changes. +- Preserve per-tab navigation state separately from shared domain state. +- Ensure optimistic deletes/updates propagate to all tabs observing the same workspace. +- Avoid global/default workspace shortcuts or active-tab data-layer access. + +Verification: +Run session repository tests and ./gradlew :app:compileDebugKotlin. Manually open two tabs to the same workspace, delete/update a session in one, and confirm the other updates without full manual refresh. + + +## Notes + +**2026-05-10T10:39:00Z** + +Added SessionRepositoryProvider keyed by server endpoint key, generation, and stable WorkspaceKey. WorkspaceViewModel now acquires a shared workspace client/repository lease and releases it on onCleared instead of constructing/closing a per-tab repository. Provider reference-counts consumers and closes the repository only on final release. SSE scopedEvents collection moved into the provider entry so a shared repository has one event consumer even when multiple tabs attach. Added SessionRepositoryProviderTest coverage for same-key reuse, reference retention until final release, replacement after final release, and generation separation. Verification: ./gradlew :app:testDebugUnitTest --tests dev.blazelight.p4oc.data.session.SessionRepositoryProviderTest --tests dev.blazelight.p4oc.data.session.SessionRepositoryImplTest; export JAVA_HOME=/usr/lib/jvm/java-17-openjdk && ./gradlew :app:compileDebugKotlin. Manual two-tabs-same-workspace update/delete smoke test still recommended. diff --git a/.tickets/oa-blgp.md b/.tickets/oa-blgp.md new file mode 100644 index 00000000..25fa5dbd --- /dev/null +++ b/.tickets/oa-blgp.md @@ -0,0 +1,26 @@ +--- +id: oa-blgp +status: closed +deps: [] +links: [] +created: 2026-05-01T17:44:25Z +type: task +priority: 1 +assignee: Jasmin Le Roux +parent: oa-gt0g +tags: [design, workspace, nav] +--- +# Design lock E: route encoding for Workspace + SessionId + +Plan deletes chat/{sessionId}?directory={directory} but doesn't say what replaces it. Options: typed Compose-nav routes (Navigation 2.8+), URL-encoded JSON arg, plain {tabId} with workspace held in tab-scoped state. Each has different deep-link/persistence implications. Decide and document. + +## Acceptance Criteria + +1) Route shape chosen and rationale documented. 2) Deep link behavior for old routes spec'd (links to design-A). 3) Path encoding for paths-with-spaces, %, ?, #, /, unicode handled. 4) SavedStateHandle vs route-args boundary defined. 5) Sub-session route behavior defined (currently Screen.Chat.createRoute(subSessionId) with no directory). + + +## Notes + +**2026-05-01T18:19:06Z** + +Decision locked. See docs/design-locks/E-route-encoding.md diff --git a/.tickets/oa-casy.md b/.tickets/oa-casy.md new file mode 100644 index 00000000..6c297e28 --- /dev/null +++ b/.tickets/oa-casy.md @@ -0,0 +1,59 @@ +--- +id: oa-casy +status: closed +deps: [oa-wmvc, oa-3yk2, oa-qy0f, oa-wxf2] +links: [oa-nwha, oa-e6g3, oa-ua2q, oa-wmvc, oa-3yk2, oa-qy0f, oa-wxf2, oa-dygk] +created: 2026-07-05T16:58:54Z +type: epic +priority: 1 +assignee: Jasmin Le Roux +--- +# Resolve four red-test complaint regressions + +Problem: +Four complaint areas now have or need intentionally red contract coverage: permission title localization boundary, slash command undo/redo dispatch semantics, model/config default source-of-truth behavior, and chat scroll restoration. The audit confirmed these are not isolated bugs; they are recurring boundary failures around upstream protocol alignment, UI/domain layering, runtime refresh, and lifecycle restoration. + +Evidence: +- Permission title: Permission.kt exposes a computed English title; InlinePermissionPrompt and NotificationEventObserver consume that preformatted domain text. Existing ticket oa-wmvc already tracks the production fix. +- Undo/redo dispatch: ChatViewModel hardcodes slash built-ins but generic typed/palette command paths route to executeCommand instead of explicit local/session APIs. +- Model/config defaults: ModelAgentManager chooses Android-side fallbacks such as build/recents/first variants rather than clearly honoring upstream/server defaults and explicit user choices. +- Scroll restoration: ChatScreen uses lifecycle-blind remember state for scroll/search/follow-tail state; source-inspection red test exists but behavior coverage should replace it. + +UX Constraint: +Do not hide protocol or lifecycle mistakes behind generic errors. Users must see workspace/session-correct behavior: permission prompts are readable/localizable, undo/redo matches intended opencode semantics, model defaults match server/config unless explicitly overridden, and restored chat tabs do not jump unexpectedly. + +Expected Behavior: +Each complaint has a failing red test that states the desired product/upstream contract, followed by production fixes that make those tests pass without shims, aliases, or implementation-token assertions. + +## Design + +Treat this as a clean contract cutover, not a compatibility shim exercise. Tests should describe the user/upstream behavior, not current implementation shape. Prefer explicit types/dispatch tables over stringly fallback chains. Keep workspace identity explicit per AGENTS.md. + +## Acceptance Criteria + +- Permission boundary is tracked through existing ticket oa-wmvc and red tests assert domain does not expose localized display title. +- Undo/redo command dispatch has contract tests for typed slash and palette paths, and production code routes to the chosen explicit handler/API instead of accidental generic executeCommand. +- Model/config defaults have contract tests for server default precedence, explicit user override boundaries, and reasoning variant default/null behavior. +- Chat scroll restoration has behavior-level tests for same-session restoration and no forced tail jump; source-inspection tests are removed or demoted after behavior tests exist. +- All child tickets include problem, evidence, UX constraints, expected behavior, acceptance criteria, and verification commands. +- Fixes satisfy targeted tests plus ./gradlew :app:compileDebugKotlin before closing. + + +## Notes + +**2026-07-06T11:35:04Z** + +Completion update from 2026-07-06: + +All four complaint regression children are now closed: +- oa-qy0f: model/agent defaults and config refresh contracts +- oa-3yk2: undo/redo slash command dispatch contracts +- oa-wxf2: chat scroll restoration behavior contract +- oa-wmvc: permission display/localization boundary + +Final verification after the last child fix: +- ./gradlew :app:testDebugUnitTest -> PASS +- ./gradlew :app:compileDebugKotlin -> PASS +- ./gradlew :app:detekt -> PASS + +The red-test complaint batch is resolved without remaining known failing tests. diff --git a/.tickets/oa-cemz.md b/.tickets/oa-cemz.md new file mode 100644 index 00000000..bed56495 --- /dev/null +++ b/.tickets/oa-cemz.md @@ -0,0 +1,26 @@ +--- +id: oa-cemz +status: closed +deps: [] +links: [] +created: 2026-05-01T17:44:25Z +type: task +priority: 1 +assignee: Jasmin Le Roux +parent: oa-gt0g +tags: [design, workspace, connection] +--- +# Design lock D: server identity / ServerRef equality + +Without a server identity model, persisted tabs can resurrect against the wrong server, and stale WorkspaceClients can survive reconnect. Decide: equal-by-baseUrl-string, equal-by-normalized-URL, equal-by-config-id, equal-by-connection-generation? What changes on reconnect to same URL? On re-auth? Is ServerRef monotonically epoch'd? + +## Acceptance Criteria + +1) ServerRef equality defined precisely. 2) Reconnect-same-URL behavior defined. 3) Re-auth behavior defined. 4) Stale-client rejection mechanism defined (ActiveServerApiProvider guard). 5) Persistence validation behavior defined (what does 'is this workspace on the active server' mean). + + +## Notes + +**2026-05-01T18:19:06Z** + +Decision locked. See docs/design-locks/D-server-identity.md diff --git a/.tickets/oa-cq7n.md b/.tickets/oa-cq7n.md new file mode 100644 index 00000000..549f1cbe --- /dev/null +++ b/.tickets/oa-cq7n.md @@ -0,0 +1,33 @@ +--- +id: oa-cq7n +status: closed +deps: [] +links: [] +created: 2026-05-10T09:45:54Z +type: task +priority: 2 +assignee: Jasmin Le Roux +--- +# Profile and reduce streaming markdown parse churn + +Problem: +StreamingMarkdown receives the full concatenated markdown text for each streaming update and recreates markdown state from that content. Long assistant messages can force repeated whole-message markdown parsing/rendering during SSE token streaming. + +Evidence: +StreamingMarkdown.kt calls rememberMarkdownState(content = text, retainState = true). ChatMessage passes text parts directly into StreamingMarkdown while SessionRepositoryImpl.applyDelta appends deltas to streaming text parts. The current comment says the library handles streaming with conflation, but there is no local benchmark or guard proving this is sufficient for long messages. + +UX Constraint: +Long streaming responses should not jank scrolling or text input. Preserve markdown correctness, code fences, syntax highlighting, and existing visual style. + +Expected Behavior: +Measure current streaming markdown cost, then either document it as acceptable or render incrementally enough that only the active tail/block reparses during streaming. + +Acceptance Criteria: +- Add a benchmark, trace, or reproducible profiling note for long streaming markdown updates. +- If jank is confirmed, split rendering by stable blocks/lines/parts or use library-supported incremental/conflated APIs correctly. +- Avoid custom markdown parsing unless the library cannot support the needed behavior. +- Preserve code block/fence rendering and tertiary styling. + +Verification: +Run relevant UI/performance test or manual profiling scenario plus ./gradlew :app:compileDebugKotlin. + diff --git a/.tickets/oa-cxjk.md b/.tickets/oa-cxjk.md new file mode 100644 index 00000000..7e79b0ee --- /dev/null +++ b/.tickets/oa-cxjk.md @@ -0,0 +1,28 @@ +--- +id: oa-cxjk +status: closed +deps: [] +links: [] +created: 2026-05-05T18:19:53Z +type: task +priority: 2 +assignee: Jasmin Le Roux +parent: oa-ssm2 +tags: [diff, library, cleanup] +--- +# Replace hand-rolled diff parser with java-diff-utils 4.15 + +Add io.github.java-diff-utils:java-diff-utils:4.15.0 (https://github.com/java-diff-utils/java-diff-utils, Apache-2.0). Use UnifiedDiffUtils.parseUnifiedDiff(...) and DiffUtils.diff(original, revised) to: + +1. Replace parseInlineDiff() at InlineDiffViewer.kt:172-196 (~80 LOC of brittle regex parsing). +2. Replace duplicated parsing logic in DiffViewerScreen.kt:52-104. +3. Provide diff computation for the editor's diff-before-save modal (see editor ticket). + +Keep the renderers — they're tightly tied to LocalOpenCodeTheme, SemanticColors.Diff, monospace TUI layout, and expandable chat cards. Just feed them structured diff models from the library. + +Introduce one internal ParsedDiff/ParsedFileDiff/ParsedHunk/ParsedDiffLine model that both viewers consume. Delete duplicate parsing. + +## Acceptance Criteria + +java-diff-utils dep added. Both InlineDiffViewer and DiffViewerScreen consume the same parsed model. Existing diff rendering output identical (visual regression test). Editor save flow uses DiffUtils.diff(server, mine) → unified diff → InlineDiffViewer. + diff --git a/.tickets/oa-d55a.md b/.tickets/oa-d55a.md new file mode 100644 index 00000000..c6c3f990 --- /dev/null +++ b/.tickets/oa-d55a.md @@ -0,0 +1,43 @@ +--- +id: oa-d55a +status: closed +deps: [oa-6d53, oa-cemz] +links: [] +created: 2026-05-01T17:45:54Z +type: task +priority: 1 +assignee: Jasmin Le Roux +parent: oa-gt0g +tags: [workspace, projects, server] +--- +# Commit 6a: Rewrite ProjectsViewModel + ServerViewModel (workspace switching + disconnect cascade) + +ProjectsViewModel.selectProject — instead of mutating global directory, construct Workspace and switch ACTIVE TAB's workspace only. ServerViewModel.disconnect — tear down all WorkspaceViewModel scopes. ServerViewModel.initializeProjectContext — delete the unconditional setDirectory(null) wipe. This is a UX semantic change: tab B unaffected when tab A switches projects. + +## Acceptance Criteria + +1) Manual smoke: tab A on project X, tab B on project Y; switch tab A to project Z; tab B STILL shows Y. 2) Manual smoke: disconnect → all tabs torn down (no zombie WorkspaceClients). 3) ProjectsViewModel has no DirectoryManager dependency. 4) ServerViewModel has no DirectoryManager + no SessionDataCache dependency. 5) initializeProjectContext does NOT wipe directory unconditionally. + + +## Notes + +**2026-05-02T12:21:56Z** + +Implemented ProjectsViewModel + ServerViewModel workspace switching rewrite. + +Summary: +- ProjectsViewModel no longer depends on DirectoryManager and no longer persists/mutates a global directory on project selection. +- ProjectsScreen now delegates project selection to its caller only. +- TabManager/TabState now track per-tab workspaceDirectory plus workspaceRevision. +- TabNavHost builds Workspace from per-tab workspaceDirectory or explicit chat route directory, not DirectoryManager. +- Project selection updates only the current tab workspace and navigates to filtered sessions. +- Workspace nav graph route now includes workspaceRevision so switching a tab project recreates that tab scoped WorkspaceViewModel/WorkspaceClient while other tabs are unaffected. +- ServerViewModel no longer depends on DirectoryManager and initializeProjectContext no longer clears directory. + +Verification: +- export JAVA_HOME=/usr/lib/jvm/java-17-openjdk && ./gradlew :app:compileDebugKotlin: BUILD SUCCESSFUL +- export JAVA_HOME=/usr/lib/jvm/java-17-openjdk && ./gradlew :app:testDebugUnitTest: BUILD SUCCESSFUL + +Greps: +- No DirectoryManager/setDirectory usage remains in ui/screens/projects or ui/screens/server. +- No DirectoryManager/getDirectory usage remains in TabNavHost. diff --git a/.tickets/oa-de13.md b/.tickets/oa-de13.md new file mode 100644 index 00000000..398a1ef5 --- /dev/null +++ b/.tickets/oa-de13.md @@ -0,0 +1,15 @@ +--- +id: oa-de13 +status: closed +deps: [] +links: [] +created: 2026-04-19T13:01:10Z +type: feature +priority: 2 +assignee: Jasmin Le Roux +external-ref: gh-1 +--- +# Support self-signed certificates via allowInsecure toggle + +Users behind reverse proxies with self-signed certs hit 'Trust anchor for certification path not found'. Added per-server 'Allow self-signed certificate' checkbox that installs a permissive TrustManager + HostnameVerifier on the OkHttp client, persisted in ServerConfig and RecentServer. + diff --git a/.tickets/oa-dpgg.md b/.tickets/oa-dpgg.md new file mode 100644 index 00000000..49eacc0d --- /dev/null +++ b/.tickets/oa-dpgg.md @@ -0,0 +1,33 @@ +--- +id: oa-dpgg +status: closed +deps: [] +links: [] +created: 2026-05-10T09:51:25Z +type: bug +priority: 0 +assignee: Jasmin Le Roux +--- +# Validate WorkspaceClient generation on every API call + +Problem: +WorkspaceClient caches the OpenCodeApi returned by ActiveServerApiProvider at construction time. This means generation/server validation runs once, then later API calls can continue using an old Retrofit API after disconnect/reconnect unless the client itself is discarded. + +Evidence: +WorkspaceClient constructor takes ActiveServerApiProvider, but stores private val api: OpenCodeApi = apiProvider.apiFor(workspace.server, generation). KoinModules ActiveServerApiProvider checks active ServerRef and ServerGeneration before returning connectionManager.requireApi(). Because WorkspaceClient caches api, those checks are bypassed on subsequent calls. + +UX Constraint: +Workspace-scoped operations must never cross server generations or silently hit a stale server. Failures should be human-readable and should not expose raw protocol payloads. + +Expected Behavior: +Every WorkspaceClient API call validates the current active server and generation before accessing OpenCodeApi, throwing a stale/inactive workspace error when the tab's client no longer matches the active connection. + +Acceptance Criteria: +- Change WorkspaceClient to resolve api through ActiveServerApiProvider per call, e.g. a getter/delegate, not an eager field. +- Prefer a domain-specific StaleWorkspaceClientException or equivalent over generic check failures if not already present. +- Add tests proving a WorkspaceClient created for generation N fails after ConnectionManager advances to generation N+1. +- Ensure all methods still pass explicit workspace.directory and do not introduce fallback chains. + +Verification: +Run WorkspaceClient/DI tests if present and ./gradlew :app:compileDebugKotlin. + diff --git a/.tickets/oa-dpm0.md b/.tickets/oa-dpm0.md new file mode 100644 index 00000000..fb53ab3c --- /dev/null +++ b/.tickets/oa-dpm0.md @@ -0,0 +1,42 @@ +--- +id: oa-dpm0 +status: closed +deps: [] +links: [] +created: 2026-07-05T18:04:23Z +type: bug +priority: 1 +assignee: Jasmin Le Roux +parent: oa-nwha +--- +# Make settings and config writes refresh active runtime state + +Problem: +Audit found settings/config writes that do not reliably update active chat/runtime state, plus optimistic writes whose failures are swallowed or only locally represented. + +Evidence: +Beyond oa-qy0f model defaults, audited areas include ProviderConfigViewModel.kt provider default model writes, ModelControlsScreen.kt setActiveModel optimistic UI update and failure handling, ModelAgentManager.kt one-shot model/agent loading, ChatViewModel.kt runtime state not observing config changes, SettingsDataStore.kt duplicated favorites/recent model flows, and ConnectionManager.kt reconnect/escalation settings sampled during active runtime windows. + +UX Constraint: +When a user changes a model/provider/agent or connection behavior, the active chat should either adopt the new state predictably or clearly explain that the change applies later. Failures must be human-readable and reversible, not silent protocol failures. + +Expected Behavior: +Active runtime managers observe the authoritative settings/config source. Writes report success/failure, refresh dependent runtime state, and avoid optimistic local divergence unless there is explicit pending/error UI. + +Acceptance Criteria: +- Define which settings apply immediately versus on next session/connection, and expose that behavior in UI copy where needed. +- Make active chat model/agent/runtime state observe provider config and DataStore changes relevant to that session/workspace. +- Handle setActiveModel failures by preserving previous state and showing a human-readable error. +- Avoid duplicated favorite/recent model local state that can diverge from DataStore. +- Add tests for provider default change refreshing active chat state and failed active-model writes. +- Verify reconnect/escalation settings are sampled or observed intentionally. + +Verification: +Run targeted ModelAgentManager, ChatViewModel, ProviderConfigViewModel, ModelControls tests. Smoke test model/provider setting changes against an active chat where feasible. + + +## Notes + +**2026-07-05T18:05:36Z** + +Superseded by oa-qy0f. Config refresh and ModelControls optimistic-write failure handling were folded into oa-qy0f as the cohesive model/config runtime-state ticket. diff --git a/.tickets/oa-dygk.md b/.tickets/oa-dygk.md new file mode 100644 index 00000000..7e6dc9f5 --- /dev/null +++ b/.tickets/oa-dygk.md @@ -0,0 +1,24 @@ +--- +id: oa-dygk +status: open +deps: [] +links: [oa-0mel, oa-casy, oa-3yk2, oa-wmvc, oa-qy0f, oa-wxf2] +created: 2026-05-09T15:47:12Z +type: feature +priority: 2 +assignee: Jasmin Le Roux +--- +# Improve slash command popup placement and metadata + +The slash command autocomplete popup can cover the current command while typing and does not clearly distinguish built-in, skill, MCP, or custom command sources. Upstream opencode anchors the popover above the prompt input and shows source badges.\n\nExpected UX:\n- Slash autocomplete never obscures the typed command or cursor.\n- Empty results are shown explicitly instead of making the popup disappear.\n- Commands display useful source metadata such as skill/MCP/custom where available.\n\nAcceptance criteria:\n- Popup is anchored above the chat input or otherwise positioned so typed text remains visible.\n- Popup handles IME/inset changes on common phone sizes.\n- Empty state is visible for unmatched input such as '/term'.\n- Skill/MCP/custom/built-in source metadata is shown when available.\n- Keyboard/dpad navigation and active-item visibility are preserved or improved. + + +## Notes + +**2026-05-09T15:52:34Z** + +Standardization note: deliver complete slash popup UX, not phased partial polish. Include placement, metadata, and interaction quality together: never cover typed command/cursor; handle IME/insets on phone sizes; show empty state for unmatched commands; show skill/MCP/custom/built-in source metadata when available; keep active item visible during keyboard/dpad navigation; preserve workspace scoping. UI chrome rule: popup is justified only as contextual autocomplete while typing '/', should disappear outside that context, and must be anchored to avoid consuming agent transcript space unnecessarily. + +**2026-05-10T15:51:10Z** + +Design constraint: slash autocomplete rows should be extremely compact and one-line. Prefer showing /name plus a short source badge, with description/agent/model omitted or heavily truncated when space is tight. The popup should prioritize keeping the typed command visible and preserving transcript space over showing full metadata. diff --git a/.tickets/oa-e07q.md b/.tickets/oa-e07q.md new file mode 100644 index 00000000..d286f463 --- /dev/null +++ b/.tickets/oa-e07q.md @@ -0,0 +1,26 @@ +--- +id: oa-e07q +status: closed +deps: [] +links: [] +created: 2026-05-05T17:48:39Z +type: feature +priority: 1 +assignee: Jasmin Le Roux +parent: oa-ssm2 +tags: [files, ofish, phase-4] +--- +# ShellCommandBuilder + ShellFileRepository (write/delete/chunked-upload) + Layer 1+2 tests + +Implement OFISH command generation. ShellCommandBuilder is a pure function (path, op, content?, expectedHash?) -> String. Single-quote escaping for paths ('\''-style); reject empty/absolute/'..' (also enforced in FileRepository per Phase 2). Use 'set -efu', trap-cleanup for temp files, mkdir -p for parents, atomic mv -f -- in same directory (NOT /tmp — cross-FS rename loses atomicity). Use printf '%s' not echo. Hash portability: sha256sum / shasum -a 256 / openssl dgst, detected via probe. Base64 portability: -d / -D / openssl base64 -d -A. Reply parser anchors on '^### \d{3}' (FISH-style trailer): 200 ok, 201 created, 204 deleted, 404 missing, 409 conflict actual=..., 412 precondition, 413 too_large, 500 error msg=..., 501 caps_missing. Chunked write: init (truncate partial) / append / finalize (atomic rename). Start chunk size 64 KiB raw (~85 KiB base64); hard cap 8 MiB total — refuse with 413 above. Crash recovery: orphaned .ofish.upload-.partial files cleaned during next capability probe. ShellFileRepository wires all of this through AppShellSessionProvider + WorkspaceClient.executeShellCommand. Layer 1 (golden-string tests) covers all path quoting edge cases. Layer 2 (ProcessBuilder against tmpdir) covers actual shell behavior on Linux + macOS. + +## Acceptance Criteria + +All Layer 1+2 tests pass on Linux + macOS CI. Manual test on phone: write a small text file, read back, hash matches; conflict on stale-hash overwrite triggers the conflict dialog; delete works; chunked write of a 1 MiB file completes. No regressions in existing FilesViewModel read flow. + + +## Notes + +**2026-05-05T17:55:45Z** + +Closed: superseded by rewritten ticket. Original spec referenced ShellCommandBuilder/ShellFileRepository with argv-bounded 64 KiB chunks and dual-impl architecture; revised plan uses heredoc-stdin payloads with 256 KiB chunks under a single OfishFileRepository — the server fast-path is invisible to the client (FISH lesson). See /tmp/opencode-signoff/file-ops-signoff.html §3-§5. diff --git a/.tickets/oa-e6g3.md b/.tickets/oa-e6g3.md new file mode 100644 index 00000000..7ed08b82 --- /dev/null +++ b/.tickets/oa-e6g3.md @@ -0,0 +1,92 @@ +--- +id: oa-e6g3 +status: open +deps: [oa-qv8d] +links: [oa-tzta, oa-ua2q, oa-casy, oa-12ui] +created: 2026-07-05T18:06:47Z +type: bug +priority: 1 +assignee: Jasmin Le Roux +parent: oa-nwha +--- +# Require explicit workspace when creating tabs + +Problem: +Tab creation APIs can create global/null-workspace tabs by omission, making workspace identity ambiguous and risking old global-directory behavior. + +Evidence: +Workspace/default audit found createTab(... workspaceDirectory: String? = null) and TabState default workspaceDirectory = null. Server-global behavior may be valid, but omission should not be indistinguishable from an intentional server-global tab. + +UX Constraint: +Workspace/project identity should be visible enough to prevent wrong-directory mistakes, without adding unnecessary persistent chrome. Creation flows should not silently default to global workspace. + +Expected Behavior: +Creating a chat/file/terminal/session tab requires an explicit workspace choice or an explicit server-global choice. Persisted TabState distinguishes intentional global workspace from missing/legacy workspace data. + +Acceptance Criteria: +- Remove or replace nullable default workspaceDirectory arguments from tab creation APIs. +- Introduce an explicit workspace scope representation for workspace directory versus intentional server-global. +- Handle legacy persisted tabs without guessing: show explicit mismatch/recovery where needed. +- Add tests proving tab creation callers must pass workspace scope. +- Add tests proving intentional server-global tabs are represented distinctly from missing workspace state. + +Verification: +Run targeted TabManager/TabState route persistence tests and compile. Smoke test creating tabs from a selected workspace. + + +## Notes + +**2026-07-06T08:53:05Z** + +Design correction from 2026-07-05 discussion: + +Do NOT introduce a new WorkspaceScope/TabWorkspace sealed hierarchy. The project already has the explicit workspace identity type: domain/server/WorkspaceKey.kt with Directory(value), Global, and SessionScoped(sessionId). Existing Workspace(server, directory) derives Workspace.key, so the fix is to stop storing raw nullable String? workspaceDirectory in TabState and start storing WorkspaceKey? directly. + +Target model: +- TabState should hold workspaceKey: WorkspaceKey? instead of workspaceDirectory: String?. +- WorkspaceKey.Directory(path) means explicit project/workspace context. +- WorkspaceKey.Global means intentional server-global/top-down context. +- WorkspaceKey.SessionScoped(sessionId) may be valid for session/event/cache contexts, but must be resolved or rejected before directory-required operations. +- null means missing legacy/ambiguous workspace identity that needs recovery; null does NOT mean Global. + +Three-bucket UX/callsite model: +1. Fresh top-level tab/menu creation = Global/top-down view. + - Tabs are flat, not hierarchical. A fresh Sessions tab should show all sessions/server-wide, not inherit the active tab's workspace. + - MainTabScreen.kt:350-352 currently passes activeWorkspaceDirectory to new Sessions tab; this should change to explicit WorkspaceKey.Global. + - MainTabScreen.kt:394-396 top-level terminal creation currently passes activeWorkspaceDirectory; it must stop implicitly inheriting. Use explicit WorkspaceKey.Global if terminal-from-menu is meant to be server default, or add an explicit workspace/server choice if product decides terminal cannot safely be global. Do not inherit by omission. +2. Contextual opens from an existing workspace/session/file keep context. + - MainTabScreen.kt:498-500 terminal opened from a specific tab should keep that tab's explicit WorkspaceKey. + - MainTabScreen.kt:541-543 files tab opened for a selected/open workspace should keep the selected explicit Directory key, or explicit Global only if the user chose server-global files. + - TabNavHost.kt:343-345 sub-session chat opened from current chat should keep workspaceOwner.workspace.key. +3. Legacy/ambiguous restored workspace-critical tabs recover instead of guessing. + - Persisted nonblank old workspaceDirectory migrates to WorkspaceKey.Directory(value). + - Known top-level/global-safe routes may migrate to WorkspaceKey.Global. + - Workspace-critical legacy routes with null/missing workspace become workspaceKey = null and show a recovery state asking the user to choose context; do not silently convert them to Global. + +Acceptance addendum: +- TabManager.createTab requires WorkspaceKey for new calls; no default nullable workspaceDirectory arg. +- TabState persistence distinguishes explicit Global from missing legacy null. +- Tests must prove fresh top-level Sessions creation uses Global, contextual opens preserve Directory, and legacy missing workspace does not become Global. +- API/repository boundary conversion remains: Directory -> directory string, Global -> null, SessionScoped -> resolve/reject depending endpoint. + +**2026-07-06T11:44:50Z** + +Decision pause from 2026-07-06: + +Before implementing the WorkspaceKey migration, we paused because the change affects tab bar plus-button semantics. Tester added red contract tests in TabManagerPersistenceTest targeting the desired WorkspaceKey API, but production has not been changed yet. + +Brainstorm consensus: +- The architecture should eventually distinguish WorkspaceKey.Directory, WorkspaceKey.Global, WorkspaceKey.SessionScoped, and legacy/missing ambiguity. +- The plus button is a product-intent question, not just a storage refactor. +- Preserving current/mobile UX likely means plus-created Sessions and Terminal should inherit the active tab's explicit workspace key, while Files should keep a chooser; contextual opens should inherit source context. +- Avoid a mechanical migration that turns plus actions into always-Global unless that product behavior is explicitly chosen. + +Recommended next decision before implementation: choose between full WorkspaceKey cutover with plus-button inheritance preserved, a smaller createTab guardrail, or deferring oa-e6g3 for another narrower P1. + +**2026-07-06T12:52:16Z** + +Blocked on oa-qv8d. Do not resume implementation until plus-button workspace semantics are decided and recorded; current red tests target a possible API but production migration is intentionally paused. + +**2026-07-06T12:54:34Z** + +Follow-up cleanup: removed the compile-red speculative WorkspaceKey contract tests from TabManagerPersistenceTest while oa-e6g3 is blocked on oa-qv8d. Left existing persistence tests compiling against current production API. Verified with ./gradlew :app:testDebugUnitTest --tests dev.blazelight.p4oc.ui.tabs.TabManagerPersistenceTest. diff --git a/.tickets/oa-e6gu.md b/.tickets/oa-e6gu.md new file mode 100644 index 00000000..766e4db5 --- /dev/null +++ b/.tickets/oa-e6gu.md @@ -0,0 +1,24 @@ +--- +id: oa-e6gu +status: closed +deps: [] +links: [] +created: 2026-05-09T15:47:05Z +type: bug +priority: 2 +assignee: Jasmin Le Roux +--- +# Make slash command loading resilient + +Slash commands/skills can fail to appear because commands are loaded lazily only when input starts with '/' and the current command list is empty. If the API fails once, built-in commands are cached and the UI may never retry loading workspace commands/skills until restart.\n\nExpected UX:\n- Opening the slash command popup should refresh or retry command loading when needed.\n- A failed load should not permanently trap the UI in built-in-only mode.\n- Users should see loading/error/empty states instead of silent disappearance.\n\nAcceptance criteria:\n- Command loading can retry after API failure.\n- Built-in fallback does not suppress future custom/skill/MCP command refreshes.\n- Slash popup shows clear loading, empty, and recoverable error states.\n- Skills/custom/MCP commands appear when returned by the server for the current workspace.\n- Behavior remains workspace-scoped; no global/default workspace fallback. + + +## Notes + +**2026-05-09T15:52:29Z** + +Standardization note: ticket should carry full context. Root cause to verify: slash commands are loaded only when input starts with '/' and command list is empty; one API failure can cache built-ins and suppress future custom/skill/MCP refresh. Expected behavior: workspace-scoped commands refresh/retry reliably; built-in fallback never prevents later server commands; loading/error/empty states are visible; raw failures are human-readable. UI chrome must be minimal and justified: popup exists only while slash input is active and must not steal agent transcript space outside that mode. + +**2026-05-10T11:24:28Z** + +Made slash command loading retryable and workspace-scoped by tracking workspace command load success separately from the built-in fallback. Opening slash UI refreshes when needed, failures keep built-ins without suppressing later retries, and inline/palette UI now show loading, retryable error, and empty states. Added ChatViewModel regression coverage for failure then retry. Verified with export JAVA_HOME=/usr/lib/jvm/java-17-openjdk && ./gradlew :app:testDebugUnitTest --tests dev.blazelight.p4oc.ui.screens.chat.ChatViewModelTest and ./gradlew :app:compileDebugKotlin. diff --git a/.tickets/oa-erzs.md b/.tickets/oa-erzs.md index c831e0f9..53a5c4f6 100644 --- a/.tickets/oa-erzs.md +++ b/.tickets/oa-erzs.md @@ -2,7 +2,7 @@ id: oa-erzs status: closed deps: [] -links: [] +links: [oa-ivwp] created: 2026-03-05T13:44:18Z type: feature priority: 3 diff --git a/.tickets/oa-es45.md b/.tickets/oa-es45.md new file mode 100644 index 00000000..58a31d31 --- /dev/null +++ b/.tickets/oa-es45.md @@ -0,0 +1,59 @@ +--- +id: oa-es45 +status: closed +deps: [] +links: [oa-a6l7, oa-1n6h, oa-t4t2, oa-764s, oa-p7ei] +created: 2026-04-19T13:57:20Z +type: task +priority: 2 +assignee: Jasmin Le Roux +external-ref: pr-3-cherrypick +tags: [networking, manifest, quick-win] +--- +# Networking plumbing: shared ConnectionPool, predictive back, nullable VcsInfo.branch + +Small plumbing wins from PR #3 that are each ~1 line but improve things. Bundle together because each is too small for its own PR. + +## Changes + +### 1. Shared OkHttp ConnectionPool +In ConnectionManager.buildBaseOkHttpClient, add: +```kotlin +private val sharedConnectionPool = ConnectionPool( + maxIdleConnections = 10, + keepAliveDuration = 5, + timeUnit = TimeUnit.MINUTES +) +``` +Apply to the base client builder so HTTP/SSE/WS all share it. + +### 2. enableOnBackInvokedCallback +AndroidManifest.xml : add `android:enableOnBackInvokedCallback="true"`. +Android 13+ predictive back preview animation. Compose BackHandler already handles this correctly, no code change needed. + +### 3. Nullable VcsInfo.branch +`data class VcsInfoDto(val branch: String? = null)` in ProjectDtos.kt. +Defensive fix — avoid MissingFieldException on projects without VCS initialized. +Update any mapper consumers to handle null branch. + +## Do NOT include + +- Context in ConnectionManager constructor (PR #3 added it for disk cache + native pool, both rejected) +- OkHttp disk Cache +- Forced protocols(HTTP_2, HTTP_1_1) +- Duplicate api.health() pre-warm +- Retrofit downgrade + +## Verification + +- ./gradlew :app:compileDebugKotlin +- Install on POCO, try swipe-back from any screen, should see peek preview on Android 14+ +- Smoke test: connect, open a project without git (if available) and verify no crash + +## Acceptance Criteria + +1. Compiles cleanly +2. App runs, connects, no regression on reverse proxy + self-signed TLS (PR #1 feature must still work) +3. Swipe-back shows predictive preview on Android 14+ +4. No crash on project with null VCS branch + diff --git a/.tickets/oa-eywk.md b/.tickets/oa-eywk.md new file mode 100644 index 00000000..40c727f0 --- /dev/null +++ b/.tickets/oa-eywk.md @@ -0,0 +1,15 @@ +--- +id: oa-eywk +status: closed +deps: [oa-pecx] +links: [] +created: 2026-03-05T19:51:10Z +type: feature +priority: 1 +assignee: Jasmin Le Roux +tags: [ui, sessions, diff] +--- +# Session diff viewer screen + +New screen showing cumulative file changes for a session using GET /session/:id/diff. Wire into session context menu and chat top bar action. Reuse existing InlineDiffViewer and DiffViewerScreen components. FileDiffDto and getSessionDiff() API already defined. + diff --git a/.tickets/oa-f0p5.md b/.tickets/oa-f0p5.md new file mode 100644 index 00000000..33f956a6 --- /dev/null +++ b/.tickets/oa-f0p5.md @@ -0,0 +1,42 @@ +--- +id: oa-f0p5 +status: closed +deps: [] +links: [] +created: 2026-07-05T18:04:23Z +type: bug +priority: 1 +assignee: Jasmin Le Roux +parent: oa-nwha +--- +# Replace Android-guessed defaults with explicit upstream or user sources + +Problem: +Audit found Android-side default values and fallback chains that can override upstream/server truth or hide missing context. The model default complaint is tracked by oa-qy0f, but similar defaulting occurs across terminal, tabs, settings, networking, DTOs, notifications, and file picking. + +Evidence: +Additional audited examples include ChatViewModel.kt hardcoded command list, PtyDtos.kt CreatePtyRequest defaults of /bin/bash, cwd ., and title Terminal, MainTabScreen.kt/TabManager.kt new tabs defaulting to global/null workspace by omission, SettingsDataStore.kt duplicated local server defaults, ServerUrl.kt default host/port/username assumptions, ProviderDtos.kt and AgentDtos.kt booleans that turn missing upstream fields into false/true, NotificationHelper.kt default notification labels, and FilePickerManager.kt root sentinel duplicated as '.'. + +UX Constraint: +Defaults should prevent friction but must not silently move work into the wrong workspace, launch the wrong shell/cwd, override server defaults, or mask upstream schema changes. Wrong-directory mistakes are especially costly on phones. + +Expected Behavior: +Default values come from upstream/server config, explicit app settings, Android resources, or a documented user-visible policy. Missing context is represented as missing, not silently guessed, unless the fallback is intentional and tested. + +Acceptance Criteria: +- Inventory hardcoded/default fallback sites from the audit and categorize source of truth for each. +- Remove fallback chains that guess workspace, shell, cwd, model, agent, or server identity. +- Require explicit workspace/cwd/shell when needed, or surface a clear setup/default-selection UI. +- Preserve nullable/missing upstream DTO fields when absence is semantically different from false/default. +- Centralize legitimate Android defaults in one config/resource layer with tests. +- Update tests that currently rely on guessed defaults. + +Verification: +Run targeted tests for model/agent defaults, PTY request creation, tab/workspace creation, DTO mapping of missing fields, and file picker roots. Run compile after implementation. + + +## Notes + +**2026-07-05T18:05:35Z** + +Superseded by oa-qy0f for model/config defaults plus narrower tickets for terminal PTY defaults and workspace/tab defaults. A broad Android-guessed defaults ticket is too large to implement as one behavior. diff --git a/.tickets/oa-ff10.md b/.tickets/oa-ff10.md new file mode 100644 index 00000000..0a586a35 --- /dev/null +++ b/.tickets/oa-ff10.md @@ -0,0 +1,39 @@ +--- +id: oa-ff10 +status: closed +deps: [] +links: [] +created: 2026-05-10T09:49:59Z +type: bug +priority: 2 +assignee: Jasmin Le Roux +--- +# Preserve inline question draft answers across configuration changes + +Problem: +InlineQuestionCard persists the pending question through DialogQueueManager/SavedStateHandle, but the user's in-progress answer selections/text are held in non-saveable Compose state. Configuration changes can keep the question while losing the user's typed response. + +Evidence: +InlineQuestionCard.kt uses var currentQuestionIndex by remember { mutableIntStateOf(0) } and val answers = remember { mutableStateMapOf>() }. ChatScreen renders InlineQuestionCard for pendingQuestion from ChatViewModel/DialogQueueManager. + +UX Constraint: +Users should not lose multi-step/custom answers to LLM questions during rotation, process recreation within saved-state limits, or tab/screen recomposition. + +Expected Behavior: +Current question index and draft answers use rememberSaveable or ViewModel-backed state keyed by question id. + +Acceptance Criteria: +- Preserve currentQuestionIndex and answers across configuration changes for the same question request. +- Reset saved draft state when a different question request is shown/submitted/cleared. +- Support multi-select/custom text answer formats currently used by InlineQuestionCard. +- Add a Compose/UI state test if practical, or document manual rotation verification. + +Verification: +Run ./gradlew :app:compileDebugKotlin and manually type/select an answer, rotate/recreate, and confirm draft remains. + + +## Notes + +**2026-05-10T11:50:34Z** + +Implemented InlineQuestionCard draft preservation with rememberSaveable keyed by question request id. Current question index and answers now survive configuration changes for the same pending request and reset when a new request id is shown or the card is removed after submit/dismiss. Custom typed answers are derived from saved selections so typed custom text is restored. Verification attempted with JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew :app:compileDebugKotlin, but compile is currently blocked by unrelated dirty-worktree errors in OfishMutationClient.kt and UploadOrchestrator.kt around changed upload byte/readBytes APIs. Manual rotation verification not run in this CLI session. diff --git a/.tickets/oa-ft0e.md b/.tickets/oa-ft0e.md new file mode 100644 index 00000000..5eafe3d1 --- /dev/null +++ b/.tickets/oa-ft0e.md @@ -0,0 +1,20 @@ +--- +id: oa-ft0e +status: closed +deps: [] +links: [] +created: 2026-05-09T15:57:54Z +type: feature +priority: 2 +assignee: Jasmin Le Roux +--- +# Move branch metadata into chat overflow + +Problem:\nThe chat header has limited space on phones and currently branch metadata competes with higher-value controls/status. The app should maximize agent transcript space and avoid persistent secondary metadata in cramped headers.\n\nEvidence:\nChat header currently renders connection dot, branch text, todo count, and overflow actions. Branch is useful but secondary compared with running/connection state and core navigation/actions.\n\nUX Constraint:\nUI chrome must be heavily justified. Branch metadata should be accessible but should not consume persistent header width when space is constrained.\n\nExpected Behavior:\nMove branch metadata from persistent chat header text into the chat overflow menu or an equally compact non-persistent surface. The overflow entry should show the current branch and optionally provide copy/open git-related actions if existing patterns support it.\n\nAcceptance Criteria:\n- Chat header no longer shows persistent branch text on compact phone layouts.\n- Chat overflow shows the current branch when available.\n- Branch display remains human-readable and truncated safely for long branch names.\n- Connection/running indicators and primary actions remain visible.\n- No workspace/project identity is hidden by this change; workspace concerns remain separately handled.\n\nVerification:\n- Verify chat header on narrow phone width has more room for core agent controls.\n- Verify overflow displays branch when branch data exists and omits it cleanly when absent. + + +## Notes + +**2026-05-10T12:06:00Z** + +Decision: leave branch metadata in the current chat header for now and close this ticket without code changes. Moving it into overflow would either create non-actionable menu content, which feels broken, or require turning branch display into a copy action that adds behavior/chrome not clearly justified. Current compact header remains acceptable unless narrow-width testing shows branch text is actively crowding core controls. diff --git a/.tickets/oa-fuc8.md b/.tickets/oa-fuc8.md new file mode 100644 index 00000000..54f765d9 --- /dev/null +++ b/.tickets/oa-fuc8.md @@ -0,0 +1,26 @@ +--- +id: oa-fuc8 +status: closed +deps: [] +links: [] +created: 2026-05-01T17:44:25Z +type: task +priority: 1 +assignee: Jasmin Le Roux +parent: oa-gt0g +tags: [ci, guards, workspace] +--- +# CI guards phase 1: forbid forward-only reward-hack patterns + +Add scripts/no-ambient-directory.sh in two-phase form. Phase 1 fails build on patterns NOT in codebase today (the temptation patterns an agent might invent during cutover). Wired into CI workflow + pre-commit. Phase 1 patterns: Workspace.global, Workspace.DEFAULT, Workspace.current, var workspace, WorkspaceManager singleton, fun withWorkspace, tabManager.activeTabWorkspace. Phase 2 (DirectoryManager, SessionDataCache, etc.) is added but commented; gets enabled in the demolition ticket. + +## Acceptance Criteria + +1) Script at scripts/no-ambient-directory.sh exists, supports --phase=1 and --phase=2 modes. 2) Phase 1 exits 0 on current main. 3) Phase 1 exits nonzero on a planted regression PR (verify by adding 'val Workspace.global = ...' temporarily). 4) Wired into .github/workflows/* AND a pre-commit hook. 5) README/AGENTS.md note explains the two phases. 6) Phase 2 patterns are present in script (commented or gated) but not enforced. + + +## Notes + +**2026-05-01T17:47:48Z** + +Cancelled — user dropped CI guard plan from cutover. diff --git a/.tickets/oa-fyag.md b/.tickets/oa-fyag.md new file mode 100644 index 00000000..7aa63622 --- /dev/null +++ b/.tickets/oa-fyag.md @@ -0,0 +1,15 @@ +--- +id: oa-fyag +status: closed +deps: [] +links: [] +created: 2026-03-05T19:51:10Z +type: feature +priority: 2 +assignee: Jasmin Le Roux +tags: [ui, chat] +--- +# VCS info bar in chat screen + +Show current git branch name and status using GET /vcs endpoint. Display as subtle info strip in chat top bar or session header. VcsInfoDto, domain model, and string resources already exist. + diff --git a/.tickets/oa-g8t3.md b/.tickets/oa-g8t3.md new file mode 100644 index 00000000..f0c45d83 --- /dev/null +++ b/.tickets/oa-g8t3.md @@ -0,0 +1,41 @@ +--- +id: oa-g8t3 +status: closed +deps: [] +links: [] +created: 2026-05-10T09:45:17Z +type: bug +priority: 1 +assignee: Jasmin Le Roux +--- +# Route chat SSE UI state through SessionRepository + +Problem: +ChatViewModel and WorkspaceViewModel both collect ConnectionManager.scopedEvents. WorkspaceViewModel forwards events into SessionRepository, while ChatViewModel also consumes raw SSE events for permissions, questions, child sessions, session status, session updates, and session errors. This creates split-brain state ownership and makes chat state depend on whether a chat ViewModel is currently alive. + +Evidence: +ChatViewModel.observeEvents() collects connectionManager.scopedEvents and handleEvent() mutates dialog queues, isBusy/isSending, childSessionIds, session state, and error state. WorkspaceViewModel also collects connectionManager.scopedEvents and calls sessionRepository.acceptEvent(). SessionRepositoryImpl currently only treats session events as repository state and does not clear streaming flags on SessionStatusChanged/SessionError unless ChatViewModel asks it to. + +UX Constraint: +Session state, streaming flags, permission/question prompts, and abort/error presentation must remain correct when the user is on Files/Terminal/Projects, when tabs are switched, and after ViewModel recreation. UI should observe domain state, not raw socket events. + +Expected Behavior: +SessionRepository is the owner of session-scoped SSE state transitions. ChatViewModel observes repository flows/state for messages, busy/idle, errors, permissions, questions, child sessions, and unread/completion signals as needed. ChatViewModel no longer directly collects ConnectionManager.scopedEvents for chat session state. + +Acceptance Criteria: +- Remove chat-session state mutation driven directly by ChatViewModel's raw scopedEvents collector. +- Move SessionStatusChanged/SessionError streaming-flag cleanup into SessionRepository or a repository-owned reducer path. +- Expose permission/question pending state from repository/domain state, or add a clearly scoped repository-owned event/state flow for them. +- Preserve workspace/server/generation routing; no global/default workspace shortcuts. +- Keep notification observers or non-state side-effect observers separate from repository state, with explicit justification. +- Add tests covering idle/error events clearing streaming flags even when no ChatViewModel is collecting raw SSE. + +Verification: +Run ChatViewModel/SessionRepository unit tests and ./gradlew :app:compileDebugKotlin. Manually verify a run can complete/abort while the user is on a non-chat tab, then returning to chat shows correct idle/error state. + + +## Notes + +**2026-05-10T10:55:18Z** + +Moved chat-session SSE state ownership into SessionRepositoryImpl. Repository now exposes sessionUiState(sessionId) with session/status/dialog/todo/error/completion state, tracks child sessions for subagent permission/question routing, clears streaming flags on SessionStatusChanged idle, SessionIdle, and SessionError without requiring a ChatViewModel collector, and owns permission/question clear operations. ChatViewModel no longer collects ConnectionManager.scopedEvents or mutates chat state from raw SSE; it observes repository sessionUiState and keeps only UI side effects such as haptics/unread/queued-send decisions. Added tests for idle/error streaming cleanup without ChatViewModel raw SSE collection. Verification: ./gradlew :app:testDebugUnitTest --tests dev.blazelight.p4oc.data.session.SessionRepositoryImplTest --tests dev.blazelight.p4oc.ui.screens.chat.ChatViewModelTest; export JAVA_HOME=/usr/lib/jvm/java-17-openjdk && ./gradlew :app:compileDebugKotlin. Manual run-complete/abort while on non-chat tab still recommended. diff --git a/.tickets/oa-gt0g.md b/.tickets/oa-gt0g.md new file mode 100644 index 00000000..f792822f --- /dev/null +++ b/.tickets/oa-gt0g.md @@ -0,0 +1,25 @@ +--- +id: oa-gt0g +status: closed +deps: [] +links: [] +created: 2026-05-01T17:43:42Z +type: epic +priority: 1 +assignee: Jasmin Le Roux +tags: [architecture, cutover, workspace] +--- +# Workspace cutover: hard migration to Workspace primitive + +Hard cutover migration replacing global mutable DirectoryManager + SessionDataCache + per-VM MessageStore with a Workspace(server, directory?) primitive owned by per-tab nav-graph-scoped WorkspaceViewModel. Deletes old code up front; no fallbacks; no feature flags. Full plan: /tmp/workspace-cutover-plan.html + +## Acceptance Criteria + +All sub-tickets closed. App compiles green. Final verification runbook passed. No DirectoryManager / SessionDataCache / MessageStore references in src. Tab-scoped persistence restores multi-tab state across process death. Two-tab workspace isolation verified manually. + + +## Notes + +**2026-05-10T11:15:31Z** + +Closing after child cleanup. All listed children are closed, oa-khzw manual verification ticket was intentionally removed as not worth maintaining, and ./gradlew :app:compileDebugKotlin passed. Legacy-cutover scan found no DirectoryManager, SessionDataCache, MessageStore class/object, Workspace.DEFAULT/global, CurrentWorkspace, listSessionsGlobal/refreshGlobal, or @Query(directory) nullable default in app source; remaining hits were workspace-scoped OFISH helpers, UI/DTO defaults, and explicit OpenCodeApi directory parameters without defaults. diff --git a/.tickets/oa-gtw8.md b/.tickets/oa-gtw8.md new file mode 100644 index 00000000..d4c8881e --- /dev/null +++ b/.tickets/oa-gtw8.md @@ -0,0 +1,35 @@ +--- +id: oa-gtw8 +status: open +deps: [] +links: [oa-v3js, oa-t3tb, oa-12ui] +created: 2026-07-05T18:06:47Z +type: bug +priority: 1 +assignee: Jasmin Le Roux +parent: oa-nwha +--- +# Preserve in-app file editor buffers across lifecycle + +Problem: +Unsaved in-app file edit buffers are lifecycle-critical and may be lost when the file viewer/editor is disposed, recreated, or switched across tabs. + +Evidence: +Lifecycle audit identified FileViewerScreen.kt unsaved edit buffer state as restoration-critical. Project instructions forbid relying on external editors for core editing workflows, so in-app editor state must be protected. + +UX Constraint: +Losing unsaved edits is a severe user-data-loss bug. File editing must remain tabbed inside P4OC by default and must respect workspace/file identity. + +Expected Behavior: +Unsaved edits are scoped by workspace, tab, and file path. Switching tabs, configuration changes, or process recreation should preserve the draft where feasible. Conflicts or unavailable files must be shown with clear recovery options, not silent overwrite/discard. + +Acceptance Criteria: +- Identify current file editor buffer source of truth and lifecycle boundaries. +- Persist unsaved edit buffers per workspace/tab/file path or explicitly store recoverable drafts. +- Detect file-on-disk changes/conflicts before saving a restored draft. +- Add behavior tests for typing edits, switching away/back, and preserving the buffer. +- Add conflict/failure tests for deleted or externally modified files if supported by repository seams. + +Verification: +Run targeted file viewer/editor tests and smoke test editing a file, switching tabs, rotating/recreating, and returning. + diff --git a/.tickets/oa-hvd4.md b/.tickets/oa-hvd4.md new file mode 100644 index 00000000..6b591d62 --- /dev/null +++ b/.tickets/oa-hvd4.md @@ -0,0 +1,16 @@ +--- +id: oa-hvd4 +status: closed +deps: [] +links: [] +created: 2026-05-07T10:03:00Z +type: task +priority: 1 +assignee: Jasmin Le Roux +parent: oa-ssm2 +tags: [files, editor, ofish, conflict] +--- +# Expose file content hash for editor conflict detection + +Sora editor save flow is wired for baselineHash but readFile/FileContentDto currently do not expose a hash, so OFISH stale-write conflicts are not triggerable from normal editor saves. Add an optional hash to file read DTO/domain state and populate it from the same OFISH/server hash source used by writes, then pass it as FileWriteRequest.expectedHash. Acceptance: editing a file, externally modifying it, then saving shows the conflict dialog instead of overwriting. + diff --git a/.tickets/oa-hysu.md b/.tickets/oa-hysu.md new file mode 100644 index 00000000..da6f3abb --- /dev/null +++ b/.tickets/oa-hysu.md @@ -0,0 +1,39 @@ +--- +id: oa-hysu +status: closed +deps: [] +links: [] +created: 2026-05-10T09:42:03Z +type: chore +priority: 3 +assignee: Jasmin Le Roux +--- +# Centralize filename MIME type resolution + +Problem: +Filename-based MIME type resolution is duplicated in chat attachment, file picker, and upload source code. This increases drift risk and makes fallback behavior inconsistent. + +Evidence: +ChatViewModel.kt has mimeTypeForFilename(), FilePickerManager.kt has mimeTypeForFilename(), and ContentResolverUploadSource.kt has mimeFromName(); all use extension parsing with MimeTypeMap.getSingleton().getMimeTypeFromExtension(...). + +UX Constraint: +Attachment and upload previews should classify files consistently. Unknown types should degrade predictably without surprising labels. + +Expected Behavior: +Use one shared utility for resolving a MIME type from a display name/path extension, and call it from chat/file-picker/upload paths. + +Acceptance Criteria: +- Add one shared helper in an appropriate core/ui-neutral location. +- Replace the three duplicated implementations with calls to the helper. +- Preserve existing fallback behavior for missing or extensionless names. +- Add a focused unit test for common extensions, uppercase extensions, and unknown/missing extension. + +Verification: +Run ./gradlew :app:testDebugUnitTest for the helper tests and ./gradlew :app:compileDebugKotlin. + + +## Notes + +**2026-05-10T12:04:58Z** + +Centralized filename MIME resolution in core/mime/FilenameMimeType. Replaced duplicate helpers in ChatViewModel, FilePickerManager, and ContentResolverUploadSource while preserving nullable fallback for picker/upload and application/octet-stream fallback for chat sends. Added focused JVM unit tests for common extensions, uppercase extensions, unknown extensions, missing names, and octet-stream fallback using an internal lookup seam because Android MimeTypeMap is not available in local unit tests. Verification: export JAVA_HOME=/usr/lib/jvm/java-17-openjdk && ./gradlew :app:testDebugUnitTest --tests dev.blazelight.p4oc.core.mime.FilenameMimeTypeTest passes; export JAVA_HOME=/usr/lib/jvm/java-17-openjdk && ./gradlew :app:compileDebugKotlin passes. diff --git a/.tickets/oa-i56p.md b/.tickets/oa-i56p.md new file mode 100644 index 00000000..62ed65f3 --- /dev/null +++ b/.tickets/oa-i56p.md @@ -0,0 +1,39 @@ +--- +id: oa-i56p +status: closed +deps: [] +links: [] +created: 2026-05-10T09:52:32Z +type: task +priority: 2 +assignee: Jasmin Le Roux +--- +# Avoid fallback-theme flash on cold start + +Problem: +PocketCodeTheme uses a fallback theme for the first Compose frame when ThemeLoader's in-memory cache is empty, then asynchronously loads the selected bundled theme and recomposes. On cold start this can show a visible color flash from fallback colors to the user's saved theme. + +Evidence: +Theme.kt produceState initialValue is ThemeLoader.getCachedTheme(themeName, darkTheme) ?: createFallbackTheme(darkTheme), then withContext(Dispatchers.IO) loads ThemeLoader.loadBundledThemeCached(context, themeName, darkTheme). ThemeLoader cache is RAM-only. + +UX Constraint: +The first visible app frame should use the user's selected theme when practical. Avoid blocking startup on expensive work, but bundled JSON theme loading is small and can be eagerly cached before UI composition if needed. + +Expected Behavior: +Theme selection is available before the first drawn app frame, or the splash/loading phase hides fallback until the selected theme is ready. + +Acceptance Criteria: +- Remove or mask the visible fallback-to-selected theme flash on cold start. +- Prefer eager Application/MainActivity theme cache preload or synchronous load only if measured cheap enough. +- Preserve async behavior for user-initiated theme switches if needed. +- Add a manual verification note for cold start with a non-default theme. + +Verification: +Run ./gradlew :app:compileDebugKotlin and cold-launch the app with a non-default theme selected. + + +## Notes + +**2026-05-10T11:54:17Z** + +Removed the first-frame fallback theme path from PocketCodeTheme. The theme is now loaded synchronously through ThemeLoader.loadBundledThemeCached inside remember(context, themeName, darkTheme), so cold start does not compose fallback colors while the bundled theme loads asynchronously. Verification: export JAVA_HOME=/usr/lib/jvm/java-17-openjdk && ./gradlew :app:compileDebugKotlin passes. Manual cold-launch visual verification with a non-default theme is still recommended on device. diff --git a/.tickets/oa-i8xb.md b/.tickets/oa-i8xb.md new file mode 100644 index 00000000..04cb2101 --- /dev/null +++ b/.tickets/oa-i8xb.md @@ -0,0 +1,15 @@ +--- +id: oa-i8xb +status: closed +deps: [oa-pecx] +links: [] +created: 2026-03-05T19:51:13Z +type: feature +priority: 3 +assignee: Jasmin Le Roux +tags: [ui, sessions] +--- +# Session summarize + +Add summarize option to session context menu using POST /session/:id/summarize. Requires providerID + modelID - use currently selected model. Display summary in dialog or inline card. SummarizeSessionRequest DTO exists. + diff --git a/.tickets/oa-it8h.md b/.tickets/oa-it8h.md new file mode 100644 index 00000000..4addaeb1 --- /dev/null +++ b/.tickets/oa-it8h.md @@ -0,0 +1,15 @@ +--- +id: oa-it8h +status: closed +deps: [oa-pecx] +links: [] +created: 2026-03-05T19:51:12Z +type: feature +priority: 1 +assignee: Jasmin Le Roux +tags: [ui, chat] +--- +# Message revert/unrevert controls + +Add per-message revert button on assistant messages with tool calls using POST /session/:id/revert. Show sticky banner when revert active with Unrevert button (POST /session/:id/unrevert). RevertSessionRequest DTO exists. Show TuiConfirmDialog before revert. + diff --git a/.tickets/oa-ivm4.md b/.tickets/oa-ivm4.md new file mode 100644 index 00000000..8552458a --- /dev/null +++ b/.tickets/oa-ivm4.md @@ -0,0 +1,39 @@ +--- +id: oa-ivm4 +status: closed +deps: [] +links: [] +created: 2026-05-10T09:52:23Z +type: task +priority: 2 +assignee: Jasmin Le Roux +--- +# Reuse stable Sora dynamic TextMate theme id + +Problem: +SoraTextMateBootstrap loads dynamically generated TextMate themes into Sora's singleton ThemeRegistry using theme names derived from mode and theme.name.hashCode(). Repeated theme changes can accumulate registry entries for the process lifetime if the registry does not replace or remove old names. + +Evidence: +SoraTextMateBootstrap.applyTheme() builds a varying themeName, creates ThemeModel(source, themeName), then calls registry.loadTheme(model) and registry.setTheme(themeName). activeThemeName is tracked but not used to remove old themes. + +UX Constraint: +Switching themes should not grow memory over time. Editors should update to the selected app theme consistently. + +Expected Behavior: +Use a stable dynamic theme id where loadTheme overwrites the prior model, or remove the previously active dynamic theme if the Sora API supports removal. + +Acceptance Criteria: +- Replace hash-varying dynamic theme names with one stable id per necessary mode/scope, or explicitly remove old dynamic themes before loading new ones. +- Preserve dark/light correctness for TextMate color scheme. +- Verify repeated theme switches do not increase registry entries if observable. +- Keep failure fallback behavior safe. + +Verification: +Run ./gradlew :app:compileDebugKotlin and manually switch themes repeatedly with a code editor open. + + +## Notes + +**2026-05-10T15:25:41Z** + +Implemented/verified stable Sora dynamic TextMate theme IDs: applyTheme now uses opencode-dynamic-dark or opencode-dynamic-light instead of including theme.name.hashCode(), so repeated theme switches reuse bounded registry names while preserving dark/light separation. Verification: export JAVA_HOME=/usr/lib/jvm/java-17-openjdk && ./gradlew :app:compileDebugKotlin passed. Manual repeated theme switch verification not run in this environment. diff --git a/.tickets/oa-ivwp.md b/.tickets/oa-ivwp.md new file mode 100644 index 00000000..7fa34dbe --- /dev/null +++ b/.tickets/oa-ivwp.md @@ -0,0 +1,35 @@ +--- +id: oa-ivwp +status: open +deps: [] +links: [oa-wmvc, oa-erzs, oa-12ui] +created: 2026-07-05T18:06:47Z +type: bug +priority: 1 +assignee: Jasmin Le Roux +parent: oa-nwha +--- +# Resource notification channel and event text + +Problem: +Notification user-facing strings are hardcoded or formatted outside a resource-backed notification boundary. This repeats the wrong-layer display problem from permission titles on a separate surface with its own Android context and notification-channel constraints. + +Evidence: +Audit identified NotificationHelper.kt notification channel/title/body strings and NotificationEventObserver permission notification text as user-visible display copy. Permission-specific title formatting is tracked in oa-wmvc, but notification channel names, notification titles, and non-permission event bodies need their own resource-backed behavior. + +UX Constraint: +Background notifications must remain concise, human-readable, and localized where possible. They must not surface raw protocol/JSON payloads or internal identifiers as primary text unless intentionally labeled as technical detail. + +Expected Behavior: +Notification channels, titles, and bodies are produced at the notification/UI boundary using Android resources and typed event data. Unknown event types still produce a safe, human-readable fallback. + +Acceptance Criteria: +- Inventory notification channel names, notification titles, and body strings emitted by NotificationHelper/NotificationEventObserver. +- Move user-facing strings and format templates to resources. +- Keep permission title formatting aligned with oa-wmvc without duplicating domain display text. +- Add/adjust tests for notification text generation using a Context/resource-backed formatter where practical. +- Unknown protocol events produce a resource-backed fallback and do not expose raw JSON as user-facing body text. + +Verification: +Run targeted notification formatter/observer tests and compile with JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew :app:compileDebugKotlin. + diff --git a/.tickets/oa-ja73.md b/.tickets/oa-ja73.md new file mode 100644 index 00000000..69fefeb5 --- /dev/null +++ b/.tickets/oa-ja73.md @@ -0,0 +1,20 @@ +--- +id: oa-ja73 +status: closed +deps: [oa-qr52, oa-vvep, oa-0f4m, oa-blgp] +links: [] +created: 2026-05-01T17:45:54Z +type: task +priority: 1 +assignee: Jasmin Le Roux +parent: oa-gt0g +tags: [tests, workspace] +--- +# Test infrastructure: workspace/session fakes + pure-function tests + +Without these, ChatViewModel/SessionListViewModel rewrites cannot be unit-tested and verification falls entirely on manual smoke. That's where reward-hacking lives. Add: FakeWorkspaceClient, FakeSessionRepository, FakeServerEventGateway. Plus pure-function tests for SessionReducer (per design-B), WorkspacePath round-trip, route encode/decode round-trip (per design-E). + +## Acceptance Criteria + +1) Fakes exist in app/src/test/.../fakes/. 2) WorkspacePath round-trip test: parseFromServer(toAttachmentUrl(p)) == p for paths with spaces/unicode/dots. 3) WorkspacePath rejects file://, absolute, blank. 4) Route encode/decode round-trip test for the chosen encoding. 5) SessionReducer test: hydrate-then-stream race buffers events correctly (per design-B). 6) Optimistic rollback test: mock HTTP 5xx → reducer rolls back (per design-C). 7) ./gradlew :app:testDebugUnitTest green. + diff --git a/.tickets/oa-jbvh.md b/.tickets/oa-jbvh.md new file mode 100644 index 00000000..57c8eacd --- /dev/null +++ b/.tickets/oa-jbvh.md @@ -0,0 +1,24 @@ +--- +id: oa-jbvh +status: closed +deps: [] +links: [] +created: 2026-05-09T15:43:07Z +type: bug +priority: 2 +assignee: Jasmin Le Roux +--- +# Add running indicator for sub-agent sessions + +Sub-agent sessions do not show a clear progress/running indicator while active. In main sessions, the Stop button effectively acts as a visible proxy for in-progress state, but sub-agent session UI lacks an equivalent status signal.\n\nSteps to reproduce:\n1. Open or start a session that launches/uses a sub-agent.\n2. Observe the main session while it is running.\n3. Observe the sub-agent session UI while the sub-agent is running.\n\nExpected: sub-agent sessions show a visible running/progress indicator comparable to the main session's in-progress affordance.\n\nActual: sub-agent sessions provide no clear visible indication that they are currently running or processing.\n\nAcceptance criteria:\n- Sub-agent sessions display a clear running/progress indicator while active.\n- The indicator appears/disappears based on the sub-agent run state.\n- The indicator is visually consistent with existing session status/progress UI.\n- Main session Stop button behavior remains unchanged. + + +## Notes + +**2026-05-09T15:52:52Z** + +Standardization note: sub-agent progress indicator should maximize agent transcript space. Do not add a persistent bulky banner unless needed. Preferred complete UX: compact status glyph/pill in sub-agent list rows, consistent status dot/color semantics, and an optional parent-session aggregate like 'N sub-agents running' only if it fits existing metadata surfaces. No fake percentages; use real run state with spinner/pulse/text. Indicator must appear/disappear from actual sub-agent run state and be accessible. + +**2026-05-10T12:16:26Z** + +Implemented broad shared-status approach for sub-agent running indicators. Added reusable SessionUiState.presence(...) helper and promoted MessageError.isAborted() to the domain model so status resolution is shared. SessionListViewModel now derives sessionPresences from repository statuses, and SessionListScreen renders all session rows, including sub-agent rows, with shared SessionStatusDot/SessionStatusRow instead of custom raw glyph/status branching. This gives sub-agent rows a compact accessible running indicator from the same SessionPresence semantics as normal sessions. ChatViewModel tab presence derivation now uses the shared SessionUiState.presence helper. Verification: JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew :app:compileDebugKotlin passed. diff --git a/.tickets/oa-jm7x.md b/.tickets/oa-jm7x.md new file mode 100644 index 00000000..0b56b2fe --- /dev/null +++ b/.tickets/oa-jm7x.md @@ -0,0 +1,20 @@ +--- +id: oa-jm7x +status: closed +deps: [] +links: [] +created: 2026-05-09T15:43:01Z +type: bug +priority: 2 +assignee: Jasmin Le Roux +--- +# Interrupting a run shows raw JSON aborted toast + +When the user interrupts/stops an in-progress run via Stop/abort, the resulting toast/snackbar can surface the raw aborted payload as JSON instead of a friendly message. This leaks internal protocol details into the UI and makes an expected user-initiated stop look like an error.\n\nSteps to reproduce:\n1. Start a chat run that produces streaming output.\n2. While the run is in progress, tap Stop / Interrupt.\n3. Observe the toast/snackbar shown after the abort response.\n\nExpected: show a concise human-readable confirmation such as 'Run stopped' / 'Run interrupted', or no toast if the stopped state is already clear.\n\nActual: toast/snackbar contains raw JSON mentioning aborted.\n\nAcceptance criteria:\n- Interrupt/Stop/abort responses are mapped before display.\n- Raw JSON is never shown for expected abort/stop outcomes.\n- User-initiated aborted events are handled distinctly from genuine failures.\n- Unexpected abort failures still show understandable errors without leaking raw response JSON. + + +## Notes + +**2026-05-09T15:52:56Z** + +Standardization note: this is a user-facing error hygiene bug. Treat user-initiated abort as an expected state transition, not an error. Do not show raw JSON/protocol payloads in toast/snackbar. Either show no toast when the stopped state is already visible, or show a concise human-readable message such as 'Run stopped'. Preserve meaningful human-readable errors for genuine failures. diff --git a/.tickets/oa-jvyb.md b/.tickets/oa-jvyb.md new file mode 100644 index 00000000..b6e4a547 --- /dev/null +++ b/.tickets/oa-jvyb.md @@ -0,0 +1,34 @@ +--- +id: oa-jvyb +status: closed +deps: [] +links: [] +created: 2026-05-10T09:41:27Z +type: bug +priority: 1 +assignee: Jasmin Le Roux +--- +# Make HydrationEventBuffer thread-safe + +Problem: +HydrationEventBuffer stores SSE events in a plain ArrayDeque while SessionRepositoryImpl can buffer incoming SSE events and replay/clear the buffer during REST hydration. If these paths overlap, ArrayDeque mutation during replay/clear can produce ConcurrentModificationException or dropped/reordered events. + +Evidence: +app/src/main/java/dev/blazelight/p4oc/data/session/HydrationEventBuffer.kt uses a mutable ArrayDeque with unsynchronized size, buffer(), replayOver(), and clear(). SessionRepositoryImpl.acceptEvent() calls hydrateBuffer.buffer(event), while hydrate() later calls hydrateBuffer.replayOver(...) and hydrateBuffer.clear(). Design lock B depends on this buffer preserving SSE hydrate-then-stream race semantics. + +UX Constraint: +Users should not see missing session updates, stuck busy state, or crashes during app start/reconnect/session hydration. Do not expose raw protocol state in UI errors. + +Expected Behavior: +Hydration buffering and replay are concurrency-safe. Events accepted during hydration are either included in a consistent replay snapshot or buffered for the next replay without corrupting the collection. + +Acceptance Criteria: +- Protect all HydrationEventBuffer reads and writes with a single synchronization strategy. +- Preserve buffer capacity eviction behavior. +- Avoid holding locks while doing expensive or callback-heavy reducer work if possible. +- Add a focused unit test that exercises buffer/replay/clear semantics, including capacity behavior. +- Keep SessionRepositoryImpl's public behavior unchanged. + +Verification: +Run ./gradlew :app:testDebugUnitTest --tests '*HydrationEventBuffer*' and ./gradlew :app:compileDebugKotlin. + diff --git a/.tickets/oa-k5fx.md b/.tickets/oa-k5fx.md new file mode 100644 index 00000000..02a96674 --- /dev/null +++ b/.tickets/oa-k5fx.md @@ -0,0 +1,36 @@ +--- +id: oa-k5fx +status: closed +deps: [] +links: [] +created: 2026-05-05T18:19:53Z +type: bug +priority: 2 +assignee: Jasmin Le Roux +tags: [terminal, resize] +--- +# Send PTY resize updates to server (PATCH /pty/{id}) + +TerminalViewModel.kt:107-124 has the server PTY resize call commented out with a stale comment claiming the API doesn't exist. The endpoint DOES exist: + - OpenCodeApi.kt:309-313: @PATCH('pty/{id}') updatePtySession(id, UpdatePtyRequest) + - PtyDtos.kt:30-33: UpdatePtyRequest(title: String?, size: PtySizeDto?) + - PtyDtos.kt:35-39: PtySizeDto(rows, cols) + +Fix: +1. Delete stale comment at TerminalViewModel.kt:107-109. +2. Restore the launch block at lines 110-124, importing UpdatePtyRequest/PtySizeDto cleanly. +3. Wrap in a 150ms debounce on (rows, cols) tuple — soft-keyboard slide animations spam dimension changes; the existing lastKnownCols/Rows guard at lines 94-99 catches identical-value spam but not animation-frame intermediate values. +4. Log AppLog.w on PATCH failure (NOT error) so local emulator resize never fails. Local resize at line 104 must always run regardless of server outcome. + +Test: run opencode serve, attach terminal, rotate device + open soft keyboard, run `tput cols` / `stty size` inside the PTY — confirm values match local emulator. + +## Acceptance Criteria + +Resize sent to server. Verified via stty size matching local emulator. Soft-keyboard slide doesn't spam PATCH (debounce confirmed). PATCH failure does not break local rendering. + + +## Notes + +**2026-05-05T18:41:38Z** + +Implemented. TerminalViewModel.kt — added pendingResize: MutableStateFlow?> + observeResizeRequests() collector with .debounce(150ms) under @OptIn(FlowPreview::class). Calls connectionManager.getApi().updatePtySession(...) directly (WorkspaceClient does not wrap PTY endpoints; PATCH /pty/{id} takes only @Path id, no directory query). PATCH failures log AppLog.w. Local emulator resize at line 104 still runs synchronously and unconditionally. Build green. diff --git a/.tickets/oa-ka08.md b/.tickets/oa-ka08.md new file mode 100644 index 00000000..6dfc9c40 --- /dev/null +++ b/.tickets/oa-ka08.md @@ -0,0 +1,41 @@ +--- +id: oa-ka08 +status: closed +deps: [oa-6zta] +links: [] +created: 2026-05-01T17:45:54Z +type: task +priority: 2 +assignee: Jasmin Le Roux +parent: oa-gt0g +tags: [docs, workspace] +--- +# AGENTS.md + openspec docs: forbidden patterns post-cutover + +Document the 10 reward-hacking traps from the plan in AGENTS.md so future agents see them. Cover: no Workspace.global/DEFAULT, no directory: String? = null defaults, no fallback chains, no tabManager.activeTabWorkspace from data layer, no var workspace, no app-global currentWorkspace singleton, no parallel ChatMessageBuffer, no compatibility route silent guess, no *Global API variants, no withWorkspace { Workspace? }. + +## Acceptance Criteria + +1) AGENTS.md updated with Forbidden Patterns section. 2) openspec/AGENTS.md updated. 3) Each pattern has: example bad code, example good code, why it's bad. + + +## Notes + +**2026-05-02T13:51:47Z** + +Updated workspace forbidden-pattern docs. + +Summary: +- Added Workspace Cutover Forbidden Patterns section to AGENTS.md. +- Created openspec/AGENTS.md with matching guidance because openspec/AGENTS.md did not exist in this checkout. +- Covered all 10 patterns with bad example, good example, and rationale: + 1. no Workspace.DEFAULT/global + 2. no directory: String? = null API defaults + 3. no directory fallback chains + 4. no data-layer active tab workspace access + 5. no mutable workspace variables + 6. no app-global current workspace singleton + 7. no parallel chat message buffers + 8. no compatibility route silent guessing + 9. no global API variants + 10. no nullable withWorkspace escape hatches. diff --git a/.tickets/oa-lmh0.md b/.tickets/oa-lmh0.md new file mode 100644 index 00000000..9071bded --- /dev/null +++ b/.tickets/oa-lmh0.md @@ -0,0 +1,33 @@ +--- +id: oa-lmh0 +status: closed +deps: [] +links: [] +created: 2026-05-05T18:25:53Z +type: task +priority: 2 +assignee: Jasmin Le Roux +parent: oa-ssm2 +tags: [license, sora, compliance] +--- +# LGPL-2.1 license compliance for SoraEditor + +SoraEditor (https://github.com/Rosemoe/sora-editor) is LGPL-2.1, verified at https://github.com/Rosemoe/sora-editor/blob/main/LICENSE. To stay compliant when shipping in a closed-source Play Store app: + +1. Add an in-app 'Open source licenses' / 'Licenses' screen — list every third-party dependency with name, version, license, and link to upstream. Include the full LGPL-2.1 text for SoraEditor specifically. +2. Display a notice in that screen that SoraEditor is dynamically integrated under LGPL-2.1 and the user has the right to replace it with a modified version. Reference upstream unmodified source at the github URL above. +3. Offer the unstripped library / relinkable object code on request — most apps do this with a contact email and 'we will provide on request' policy. Add to privacy/licenses page. +4. DO NOT modify SoraEditor's source. All integration must use public APIs (CodeEditor, ThemeRegistry, EditorColorScheme, TextMateLanguage, subscribeEvent). If we need behavior Sora doesn't expose, contribute upstream rather than fork. +5. R8/ProGuard shrinking is allowed — it's not 'modifying' under LGPL. +6. Same screen should also list other libraries (Compose, OkHttp, Retrofit, Termux libs, Coil, Koin, kotlinx, mikepenz markdown, eventsource — all permissive) for completeness. + +Generate the licenses screen mostly automatically: use a Gradle plugin like 'com.mikepenz:aboutlibraries-plugin' (Apache-2.0) which scans dependencies and generates a Compose 'LibrariesContainer' screen, OR use 'com.google.android.gms:oss-licenses-plugin' (the Google one, but Play Services dependency is heavier). + +Recommendation: 'com.mikepenz:aboutlibraries-plugin' — already same vendor as the markdown renderer (consistency), small, Compose-native screen out of the box. + +Termux libraries already in app/build.gradle.kts:148-151 are GPL-3.0; verify they are properly attributed in the same screen — they are also LGPL-style obligations. + +## Acceptance Criteria + +App has a Licenses screen accessible from Settings/About. SoraEditor and Termux libraries listed with full license text, version, and upstream link. Privacy/licenses note offers object-code-on-request for LGPL deps. PR review confirms no SoraEditor source modifications (only public-API usage). Manual check: open Play Store listing — license obligations met. + diff --git a/.tickets/oa-m5a8.md b/.tickets/oa-m5a8.md new file mode 100644 index 00000000..fac3c575 --- /dev/null +++ b/.tickets/oa-m5a8.md @@ -0,0 +1,33 @@ +--- +id: oa-m5a8 +status: closed +deps: [] +links: [] +created: 2026-05-10T09:49:33Z +type: bug +priority: 1 +assignee: Jasmin Le Roux +--- +# Rethrow CancellationException in UI coroutine catches + +Problem: +Several UI ViewModels/components catch Exception inside coroutine paths without first rethrowing CancellationException. This can swallow coroutine cancellation, write fake errors after a screen is leaving, and violate structured concurrency expectations. + +Evidence: +SessionListViewModel has multiple catch (e: Exception) blocks inside viewModelScope launches. ProviderConfigViewModel and other UI classes also have blanket catches. UploadOrchestrator already handles CancellationException correctly, and safeApiCall is expected to preserve cancellation. + +UX Constraint: +Navigating away, closing tabs, or cancelling jobs should not produce stale snackbar errors or keep work alive. Real failures should remain human-readable. + +Expected Behavior: +Coroutine code that catches Exception/Throwable either rethrows CancellationException first or uses helper APIs that preserve cancellation. + +Acceptance Criteria: +- Audit UI-layer coroutine catch blocks for catch(Exception)/catch(Throwable). +- Add catch (CancellationException) { throw it } before generic catches where the code can run in a coroutine. +- Avoid swallowing cancellation in helper functions called from coroutines. +- Add or update tests where practical for cancelled operations not setting error UI state. + +Verification: +Run ./gradlew :app:testDebugUnitTest for affected ViewModels and ./gradlew :app:compileDebugKotlin. + diff --git a/.tickets/oa-n1fs.md b/.tickets/oa-n1fs.md new file mode 100644 index 00000000..b616fa72 --- /dev/null +++ b/.tickets/oa-n1fs.md @@ -0,0 +1,35 @@ +--- +id: oa-n1fs +status: open +deps: [] +links: [oa-4olr, oa-x9pe, oa-12ui] +created: 2026-07-05T18:06:47Z +type: bug +priority: 1 +assignee: Jasmin Le Roux +parent: oa-nwha +--- +# Restore terminal scrollback and terminal identity lifecycle state + +Problem: +Terminal scrollback/emulator state and current terminal identity are lifecycle-sensitive and may be lost or recreated unexpectedly when switching tabs, rotating, or restoring the app. + +Evidence: +Lifecycle audit identified TerminalViewModel.kt and TerminalScreen.kt scrollback/emulator state as restoration-critical. Terminal copy/paste and InputConnection tickets exist separately, but they do not cover lifecycle restoration. + +UX Constraint: +Terminal context is part of the agent/code workspace. Users must not lose terminal output context or accidentally interact with a different terminal after returning to a tab. Do not fake process restoration if the backend PTY is gone; surface that state clearly. + +Expected Behavior: +Returning to a terminal tab restores the same terminal session identity and visible scrollback when the backend session is still available. If the backend PTY/session is gone, UI shows a clear disconnected/restart affordance rather than silently creating a new unrelated terminal. + +Acceptance Criteria: +- Define terminal identity persistence per workspace/tab. +- Preserve/restores visible scrollback or emulator buffer for still-active terminal sessions where feasible. +- Distinguish restored active terminal from closed/lost terminal with human-readable state. +- Do not create a new PTY silently when restoring a tab that referenced a previous terminal. +- Add targeted ViewModel/Compose tests for tab switch and recreation behavior. + +Verification: +Run targeted terminal ViewModel/UI tests and smoke test tab switch/recreate with an active terminal. + diff --git a/.tickets/oa-n86n.md b/.tickets/oa-n86n.md new file mode 100644 index 00000000..096737bc --- /dev/null +++ b/.tickets/oa-n86n.md @@ -0,0 +1,33 @@ +--- +id: oa-n86n +status: closed +deps: [oa-s5jj] +links: [] +created: 2026-05-05T17:57:03Z +type: task +priority: 2 +assignee: Jasmin Le Roux +parent: oa-ssm2 +tags: [files, tests, ci, phase-6] +--- +# Layer 3 integration tests against real opencode serve in CI + +Spin up a real opencode-server in CI Docker. Add app/src/androidTest/ directory if missing. Instrumentation tests use ConnectionManager to point at the dockerized server. + +Test scenarios: +- OFISH session created on first file op; reused; not visible in user-facing session list (post client-side filter). +- Capability probe completes against busybox base image AND ubuntu:22.04 AND macOS-style shasum env (use a shim if needed). +- Write small file, read back, hash matches. +- Overwrite with correct expectedHash → 200; with stale → 409 conflict dialog. +- Delete existing → 204; delete missing → 404. +- Upload 1 MiB binary in chunks → final hash matches client-computed pre-upload hash. +- Concurrent writes from two pretend clients (or two OFISH sessions) — second one hits 409. +- Capability missing scenario: stub probe response with 501; UI disables Save/Delete/Upload. +- Permission auto-approval: matching callID auto-approves once; unmatched callID surfaces dialog. + +Run nightly initially. Promote to required pre-merge once stable. + +## Acceptance Criteria + +CI Dockerfile committed under app/src/androidTest/docker/. Workflow runs the full scenario list nightly and reports green. At least one alpine/busybox + one debian + one macOS-shim image covered. + diff --git a/.tickets/oa-na7x.md b/.tickets/oa-na7x.md new file mode 100644 index 00000000..35b1b43d --- /dev/null +++ b/.tickets/oa-na7x.md @@ -0,0 +1,39 @@ +--- +id: oa-na7x +status: closed +deps: [] +links: [] +created: 2026-05-10T09:49:26Z +type: bug +priority: 0 +assignee: Jasmin Le Roux +--- +# Prevent SSE event buffer from dropping delta events + +Problem: +OpenCodeEventSource uses MutableSharedFlow buffers with BufferOverflow.DROP_OLDEST. For delta-based message streams, silently dropping older events can corrupt assistant text or session state if consumers lag. + +Evidence: +OpenCodeEventSource declares _events and _directoryEvents with replay = 0, extraBufferCapacity = 256, onBufferOverflow = BufferOverflow.DROP_OLDEST. SessionRepositoryImpl.applyDelta appends message part delta text, so lost MessagePartUpdated events can mean lost characters/words. + +UX Constraint: +Chat output must never silently corrupt streamed text. If the app cannot keep up, it should apply backpressure, recover through hydration, or surface a human-readable sync/reconnect state rather than showing incomplete text as if valid. + +Expected Behavior: +SSE event delivery to repository-owned state is lossless for ordered delta streams, or has explicit recovery semantics that rehydrates before rendering final state. + +Acceptance Criteria: +- Replace DROP_OLDEST with a lossless/backpressured strategy or introduce explicit overflow recovery that rehydrates affected sessions. +- Preserve stale generation protection and directory routing. +- Confirm LaunchDarkly callback threading is not blocked in a way that deadlocks shutdown/reconnect; if SUSPEND cannot be used directly from callback code, use a dedicated channel/actor with safe backpressure. +- Add a test or stress harness that emits more than the previous buffer capacity of ordered delta events and verifies no text is lost. + +Verification: +Run new SSE buffer tests and ./gradlew :app:compileDebugKotlin. + + +## Notes + +**2026-05-10T10:17:39Z** + +Implemented lossless ordered SSE event pump: LaunchDarkly callbacks enqueue into an internal unlimited channel, and a single IO coroutine emits to zero-buffer SharedFlows with suspension/backpressure instead of DROP_OLDEST. Added OpenCodeEventSourceTest stress coverage for 300 ordered message.part.updated deltas with a slow collector. Verification: ./gradlew :app:testDebugUnitTest --tests dev.blazelight.p4oc.core.network.OpenCodeEventSourceTest; export JAVA_HOME=/usr/lib/jvm/java-17-openjdk && ./gradlew :app:compileDebugKotlin diff --git a/.tickets/oa-nam8.md b/.tickets/oa-nam8.md new file mode 100644 index 00000000..60964723 --- /dev/null +++ b/.tickets/oa-nam8.md @@ -0,0 +1,43 @@ +--- +id: oa-nam8 +status: closed +deps: [] +links: [] +created: 2026-07-05T18:04:23Z +type: bug +priority: 1 +assignee: Jasmin Le Roux +parent: oa-nwha +--- +# Persist lifecycle-critical chat file terminal and picker state + +Problem: +Audit found restoration-critical UI state stored in plain in-memory Compose/ViewModel state across chat, file, terminal, picker, command-palette, and session-list flows. Chat scroll restoration is tracked separately in oa-wxf2, but the same lifecycle-blind pattern affects other user state. + +Evidence: +Additional audited areas include ChatViewModel.kt draft/queued message/attachment state, FilePickerManager.kt picker state, ChatInputBar.kt input attachment UI state, FileViewerScreen.kt unsaved edit buffer, FilesViewModel.kt and FileExplorerScreen.kt path/search/symbol/filter state, TerminalViewModel.kt and TerminalScreen.kt scrollback/emulator state, ModelAgentManager.kt selected model/reasoning state, CommandPalette.kt draft args, and SessionListScreen.kt/SessionListViewModel.kt search and tree expansion. + +UX Constraint: +The app is an agent/chat/code workspace. Losing drafts, edit buffers, terminal context, search state, or file navigation after tab switches, process recreation, or configuration changes can cause wrong-directory mistakes and user data loss. + +Expected Behavior: +Each piece of user-authored or restoration-critical state is scoped to the owning workspace/tab/session/file/terminal and survives the lifecycle events appropriate to its risk level. State that cannot be safely restored must fail transparently with a human-readable recovery path. + +Acceptance Criteria: +- Classify audited state as ephemeral, saveable, persisted, or intentionally non-restorable. +- Persist chat draft/queued attachments per session/workspace where safe. +- Protect unsaved file edit buffers inside the app workspace; do not rely on external editors. +- Preserve file explorer path/search/symbol filters per tab/workspace where appropriate. +- Preserve terminal scrollback/current terminal identity to the extent feasible without fabricating process state. +- Preserve command palette draft args and session-list search/tree state when returning to the relevant tab. +- Add behavior tests for at least the high-risk data-loss cases: chat draft, unsaved file buffer, file explorer path/search, and terminal scrollback/identity. + +Verification: +Use targeted ViewModel/SavedStateHandle tests and Compose behavior tests. For terminal/file editor cases, smoke test tab switch and recreation paths. Run compile after implementation. + + +## Notes + +**2026-07-05T18:05:35Z** + +Superseded by narrower lifecycle tickets to be created under oa-nwha. Chat scroll remains oa-wxf2; file editor buffers, terminal scrollback, file explorer state, and session list state need separate cohesive behavior tickets. diff --git a/.tickets/oa-nhg0.md b/.tickets/oa-nhg0.md new file mode 100644 index 00000000..d5dc2a7b --- /dev/null +++ b/.tickets/oa-nhg0.md @@ -0,0 +1,46 @@ +--- +id: oa-nhg0 +status: closed +deps: [oa-6zta, oa-cemz] +links: [] +created: 2026-05-01T17:45:54Z +type: task +priority: 1 +assignee: Jasmin Le Roux +parent: oa-gt0g +tags: [workspace, persistence] +--- +# Commit 9: tab-scoped persistence (process-death restoration) + +Persists open tabs + per-tab active SessionId/Workspace via TabManager.saveState/restoreState as versioned JSON in DataStore. Per-tab SavedStateHandle for active session. Restore validates Workspace belongs to active server (per design-D) before resurrecting; explicit error UI on mismatch, not silent fallback. NON-NEGOTIABLE: lands in same branch as cutover — without this, cutover ships clean architecture + UX regression. + +## Acceptance Criteria + +1) Manual: open two tabs different workspaces → force-stop app → reopen → both tabs restored to correct workspace+session. 2) Manual: change server URL → reopen → explicit error UI for stale tabs, NO silent global state. 3) Manual: open session, kill app, reopen — lands on same session (replaces dead lastSessionId). 4) Persisted JSON has version field. 5) Migration handles version mismatch. 6) No resurrection of old lastSessionId / project_worktree semantics under different names. + + +## Notes + +**2026-05-02T13:44:59Z** + +Implemented tab-scoped persistence. + +Summary: +- Added versioned PersistedTabState/PersistedTab JSON in SettingsDataStore under tab_state_v1. +- Added get/set persisted tab state APIs. +- TabManager now saves open tabs with activeTabId, per-tab sessionId/sessionTitle, workspaceDirectory, and active server endpoint key. +- TabManager restore validates PersistedTabState.version and active ServerRef endpoint key before resurrecting tabs. +- Server mismatch/version mismatch returns explicit RestoreResult; MainTabScreen displays a snackbar and starts fresh instead of silently falling back to global state. +- Session tabs restore as chat routes carrying the same session and workspace directory. +- Terminal tabs intentionally restore as sessions root, not stale PTY sessions. +- MainTabScreen restores once after active server is known and saves whenever tabs/active tab/current server changes. +- Added TabManagerPersistenceTest coverage for versioned save, same-server restore, server mismatch, version mismatch, and terminal route sanitization. + +Verification: +- export JAVA_HOME=/usr/lib/jvm/java-17-openjdk && ./gradlew :app:compileDebugKotlin: BUILD SUCCESSFUL +- export JAVA_HOME=/usr/lib/jvm/java-17-openjdk && ./gradlew :app:testDebugUnitTest --tests 'dev.blazelight.p4oc.ui.tabs.*': BUILD SUCCESSFUL +- export JAVA_HOME=/usr/lib/jvm/java-17-openjdk && ./gradlew :app:testDebugUnitTest: BUILD SUCCESSFUL + +Greps: +- No old lastSessionId/project_worktree APIs reintroduced. +- Remaining project_worktree/last_session_id literals are only in the migration that removes old prefs. diff --git a/.tickets/oa-nwha.md b/.tickets/oa-nwha.md new file mode 100644 index 00000000..73508565 --- /dev/null +++ b/.tickets/oa-nwha.md @@ -0,0 +1,33 @@ +--- +id: oa-nwha +status: open +deps: [] +links: [oa-qy0f, oa-wmvc, oa-wxf2, oa-casy, oa-3yk2] +created: 2026-07-05T18:03:15Z +type: epic +priority: 1 +assignee: Jasmin Le Roux +--- +# Resolve audit-wide source-of-truth and lifecycle regressions + +Problem: +The four red-test complaint regressions exposed broader classes of source-of-truth, layer-boundary, lifecycle, fallback, and test-contract problems across the app. Audit agents found additional instances beyond the original permission title, undo/redo dispatch, model defaults, and chat scroll restoration tickets. + +Evidence: +Audits found further instances in built-in command semantics, hardcoded display strings outside resource/UI boundaries, lifecycle-blind UI state, Android-guessed defaults, stale runtime after settings writes, workspace/global fallback leakage, and tests preserving implementation details. + +UX Constraint: +P4OC must keep workspace/session/user state predictable on phones. Fixes should avoid adding persistent chrome unless justified, should keep core editing/chat workflows in-app, and should make failure states human-readable instead of surfacing protocol or JSON details. + +Expected Behavior: +Android should treat upstream/server protocol, resource-backed UI formatting, workspace/session-scoped state, and observable settings/config as authoritative. Local fallbacks must be explicit, tested, and user-visible when they represent degraded behavior. + +Acceptance Criteria: +- Child tickets cover each audited problem family with concrete files and evidence. +- Existing original complaint tickets remain linked rather than duplicated. +- Each child ticket defines user-facing expected behavior and verification notes. +- Follow-up work removes or rewrites tests that encode incorrect implementation contracts. + +Verification: +Use targeted unit/androidTest/Compose tests per child ticket. Run compile/detekt only after implementation work, not as part of ticket creation. + diff --git a/.tickets/oa-of3b.md b/.tickets/oa-of3b.md new file mode 100644 index 00000000..44198b0d --- /dev/null +++ b/.tickets/oa-of3b.md @@ -0,0 +1,34 @@ +--- +id: oa-of3b +status: closed +deps: [] +links: [] +created: 2026-05-10T09:51:56Z +type: bug +priority: 2 +assignee: Jasmin Le Roux +--- +# Avoid per-frame coroutine launches in PTY WebSocket onMessage + +Problem: +PtyWebSocketClient launches a new coroutine for every WebSocket text frame just to emit terminal output. High-volume terminal output can allocate thousands of coroutines per second, causing GC churn and latency. + +Evidence: +PtyWebSocketClient.onMessage checks generation, logs, then calls scope.launch { _output.emit(text) }. _output is a MutableSharedFlow(extraBufferCapacity = 1000), so synchronous tryEmit can usually enqueue without allocating a coroutine per message. + +UX Constraint: +Terminal output must remain ordered and responsive under verbose commands without overheating/freezing the app. If terminal output is dropped due to overflow, it should be logged and ideally recoverable/visible as terminal stream loss rather than corrupting app state. + +Expected Behavior: +WebSocket onMessage uses a low-allocation emission path such as tryEmit or a dedicated actor/channel, preserving generation checks and output ordering. + +Acceptance Criteria: +- Remove per-message scope.launch allocation from onMessage. +- Use tryEmit or a dedicated single consumer that avoids unbounded coroutine creation. +- Decide and document overflow behavior for PTY output frames. +- Keep stale generation callbacks ignored. +- Add a stress/manual verification with high-volume terminal output. + +Verification: +Run ./gradlew :app:compileDebugKotlin and manually run a verbose terminal command while monitoring responsiveness/logs. + diff --git a/.tickets/oa-p7ei.md b/.tickets/oa-p7ei.md new file mode 100644 index 00000000..5b1d9c1a --- /dev/null +++ b/.tickets/oa-p7ei.md @@ -0,0 +1,127 @@ +--- +id: oa-p7ei +status: closed +deps: [] +links: [oa-a6l7, oa-1n6h, oa-t4t2, oa-764s, oa-es45] +created: 2026-04-19T13:58:18Z +type: feature +priority: 1 +assignee: Jasmin Le Roux +external-ref: pr-3-cherrypick +tags: [networking, perf, sessions] +--- +# Unify connect probe + session prefetch via shared Deferred — drop /health, save ~700ms + +## Error handling + +- listProjects returns 401/403 → auth failure, ConnectionState.Error, disconnect +- listProjects returns 404 / network failure → server not reachable or not OpenCode, ConnectionState.Error +- listProjects returns 200 with [] → valid empty project list, proceed, no sessions to prefetch +- prewarm failure (network flap mid-prefetch) → log warn, SessionListVM falls back to fresh fetch on open +- Disconnect mid-prefetch → invalidate() cancels Deferred, no stale state + +## Connect flow changes + +ConnectionManager.connect: +- Replace `val healthResult = runCatching { api.health() }` with `val probeResult = runCatching { withTimeout(8000) { api.listProjects() } }` +- On success, keep projects around; pass them to cache.prewarm as seed so the cache doesn't re-fetch projects +- DO NOT add Context constructor param (rejected) +- DO NOT add OkHttp disk Cache (rejected) +- DO NOT add duplicate api.health() pre-warm (rejected) + +ServerViewModel.connect callsite: +- After connectionManager.connect succeeds, call sessionDataCache.prewarm(projectsFromProbe) +- Rely on current SSE connection state flow for UI → no change + +ConnectionManager.disconnect: +- Call sessionDataCache.invalidate() before clearing _connection + +## Expected win + +On 5-project server with 100ms RTT: +- -1 RTT from removing health call +- -1 full fan-out from Deferred sharing (VM doesn't re-fetch) +- -(N-1) × RTT from parallelizing statuses +Total: roughly 700–900ms from tap Connect to sessions populated + +## Blocks on + +- oa-t4t2 (PR E benchmarks) for baseline measurement + +## Verify + +- Run StartupBenchmark + tap-connect-to-sessions-rendered trace before and after +- Manual: connect, should see sessions appear immediately after nav animation +- Manual: disconnect mid-prefetch, reconnect — no stale data +- Manual: switch from server A to server B, verify no A's sessions shown after B connects + +Kill the dedicated /health round-trip on connect. Use listProjects() as the probe — it both validates the server is OpenCode-shaped AND returns data we need for the session list. Share the in-flight Deferred between ServerViewModel (prewarm) and SessionListViewModel (consume) so we never duplicate the fan-out. + +## Current flow (main) + +``` +connect() → api.health() ~RTT + → buildSseClient + store connection + → navigate to Sessions ~300ms anim + → SessionListVM.loadSessions() listProjects + N parallel listSessions + → SessionListVM.loadSessionStatuses() N sequential getSessionStatuses ← wasteful +``` + +## Proposed flow + +``` +connect() → withTimeout(8000) { api.listProjects() } ← this IS the probe, returns projects + → start SSE + → cache.prewarm(projects) fires listSessions fan-out + statuses in parallel + → complete Result.success +ServerVM navigates +SessionListVM.init → cache.awaitOrFetch() ← joins in-flight Deferred, no duplicate requests +``` + +## New: SessionDataCache + +File: app/src/main/java/dev/blazelight/p4oc/core/network/SessionDataCache.kt + +Responsibilities: +- Singleton (Koin) +- Hold @Volatile Deferred>? +- prewarm(seedProjects) kicks off fan-out, stores Deferred +- awaitOrFetch() returns the in-flight Deferred's result (or starts a new one if null) +- invalidate() cancels in-flight + clears on disconnect +- Server-identity check via connectionManager.currentBaseUrl (add this getter) +- 30s freshness window for reconnect +- **Concurrency limit 10 in-flight, NO project count cap** — use Semaphore(10) around each async{} so all projects prefetch, just throttled + +Data shape: +```kotlin +data class CachedSessions( + val sessions: List, + val projects: List, + val statuses: Map, // NEW — cache statuses too + val fetchedAtMs: Long, + val serverBaseUrl: String +) +``` + +## Also in this PR + +### Parallelize loadSessionStatuses +Currently SessionListViewModel.loadSessionStatuses runs N sequential getSessionStatuses calls (one per project + global). Convert to async/awaitAll with the same Semaphore(10) gate. This is a pure bug fix — no sequential reason. + +### withTimeout(8000) on the connect probe +Wraps listProjects instead of health. + +### api.health() stays defined +Unused in connect path, available for future test + +## Acceptance Criteria + +1. Sessions screen paints immediately on first nav after connect (LAN) +2. No duplicate network requests between prewarm and SessionListVM +3. On-disconnect invalidate cancels pending Deferred +4. Server-switch shows correct server's sessions only +5. Benchmark delta >300ms improvement vs baseline (from PR E) +6. PR #1 self-signed TLS toggle still works +7. Unit tests for SessionDataCache Deferred-sharing semantics +8. No regression on sequential loadSessionStatuses (now parallel) + diff --git a/.tickets/oa-p829.md b/.tickets/oa-p829.md new file mode 100644 index 00000000..3000ab04 --- /dev/null +++ b/.tickets/oa-p829.md @@ -0,0 +1,40 @@ +--- +id: oa-p829 +status: closed +deps: [oa-vg20] +links: [] +created: 2026-05-05T17:57:03Z +type: feature +priority: 1 +assignee: Jasmin Le Roux +parent: oa-ssm2 +tags: [files, ofish, phase-3] +--- +# OFISH session provider + capability probe + callID-based permission broker + +OfishSessionProvider: lazily creates and caches a per-workspace session titled '__ofish__' for app-driven shell. NEVER use the user's chat session — executeShellCommand returns a MessageWrapperDto that becomes a real chat message and would pollute history with base64 blobs. Cached on the WorkspaceClient (per-workspace, per-generation, AGENTS.md-compliant). Filtered from visible session lists by client-side title-prefix filter until server-side metadata exists. + +Capability probe: first command sent on the OFISH session is the FISH-style HELLO/VER handshake. Probes for base64 (-d/-D/openssl), sha256 (sha256sum/shasum/openssl dgst), mv/mkdir/rm/awk. Caches result on WorkspaceClient. If essentials missing, FileRepository write/delete/upload throw a typed CapabilitiesUnavailable error and UI disables Save/Delete/Upload with a banner explaining why. + +Permission broker: REUSE the server-issued callID via the existing pendingPermissionsByCallId mechanism (ChatMessage.kt:178-186). When a permission event arrives on the OFISH session for a tool call belonging to an in-flight FileRepository operation, auto-reply 'once'. NEVER auto-reply 'always'. Permissions for tool calls outside the in-flight set bubble up to a confirmation dialog. + +Replaces the previously-filed (and closed) oa-ryve which incorrectly invented an app-generated op-id and a separate AppShellSessionProvider abstraction. + +## Acceptance Criteria + +First-connect probe completes, caps cached. OFISH session created on first file op, reused thereafter. OFISH session does not appear in user's session list (filtered client-side; if server adds metadata API, switch to that). Permission auto-approval verified: a saving file op auto-replies 'once' on the matching callID; an unrelated permission event surfaces a dialog. Layer 2 tests cover: probe parsing for GNU/macOS/BSD, missing-tool detection, permission-callID-matched auto-approve, permission-callID-unmatched bubbling. + + +## Notes + +**2026-05-05T18:19:53Z** + +Per council directive 4: OfishSessionProvider becomes a session FACTORY, not a per-workspace cache singleton. Two-tier policy: (1) per-operation ephemeral session for multi-chunk writes/uploads; (2) optional 30-60s idle-TTL pooled session for small single-shot writes (defer to v2 if create+delete latency proves negligible). On workspace connect: sweep sessions with title prefix __ofish_ older than N minutes. + +**2026-05-05T18:25:53Z** + +Correction (user feedback): drop 'versioned from day one to avoid breaking older clients' rationale. There ARE no older clients — we control both ends of the protocol. The capability probe is about detecting the server's SHELL ENVIRONMENT (sha256sum/shasum/openssl, base64 -d/-D), not protocol versioning. Replace '#VER OFISH/0.0.1' with just '#OFISH_HELLO' returning the caps line. The asymmetry runs the other way: server can be old, client always runs latest — and that's already handled by the probe + '### 501 caps_missing' degradation. + +**2026-05-05T19:22:00Z** + +Implemented and committed in f024318. Added ephemeral OFISH session factory, workspace-scoped adapter, stale session sweeper, caps command/parser/probe, conservative permission auto-approver, OFISH session filtering in hydration/reducer, and focused tests. No protocol versioning, no write/upload implementation, no chunk caps. Verified compileDebugKotlin and focused OFISH/session tests green. diff --git a/.tickets/oa-p98b.md b/.tickets/oa-p98b.md new file mode 100644 index 00000000..8155e4f5 --- /dev/null +++ b/.tickets/oa-p98b.md @@ -0,0 +1,58 @@ +--- +id: oa-p98b +status: closed +deps: [oa-6d53] +links: [] +created: 2026-05-01T17:45:54Z +type: task +priority: 1 +assignee: Jasmin Le Roux +parent: oa-gt0g +tags: [workspace, api] +--- +# Commit 3: STRIP directory defaults from OpenCodeApi + +Remove '= null' default from every @Query("directory") directory: String? param in OpenCodeApi.kt (~20 methods). Param stays required-nullable; the default is what's gone. This breaks compilation at every call site that doesn't go through WorkspaceClient — that IS the forcing function. Tree red after this commit until commit 8. + +## Acceptance Criteria + +1) git diff on OpenCodeApi.kt shows ONLY removals of '= null' on @Query("directory") params. 2) No new overloads introduced (no listSessionsGlobal() escape hatches). 3) No callers commented out (// directoryManager pattern). 4) Expected list of broken files documented in ticket BEFORE commit; actual ./gradlew :app:compileDebugKotlin failure set matches. 6) Tree IS red — that's expected. + + +## Notes + +**2026-05-02T11:34:44Z** + +Expected red-tree compile breakage before stripping OpenCodeApi directory defaults: + +Known likely broken files from direct call-site scan: +- app/src/main/java/dev/blazelight/p4oc/data/session/SessionRepositoryImpl.kt +- app/src/main/java/dev/blazelight/p4oc/core/network/SessionDataCache.kt +- app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModel.kt +- app/src/main/java/dev/blazelight/p4oc/ui/screens/sessions/SessionListViewModel.kt +- app/src/main/java/dev/blazelight/p4oc/ui/screens/diff/SessionDiffScreen.kt + +Possible test compile fallout: +- app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModelTest.kt + +Expected cause: legacy/direct OpenCodeApi callers that relied on @Query("directory") directory: String? = null defaults rather than passing explicit workspace-scoped directory through WorkspaceClient. This ticket intentionally does not fix call sites. + +**2026-05-02T11:36:16Z** + +Actual verification after stripping OpenCodeApi directory defaults: + +Diff scope: +- app/src/main/java/dev/blazelight/p4oc/core/network/OpenCodeApi.kt only. +- All @Query("directory") directory: String? = null defaults were removed. +- No @Query("directory") defaults remain in OpenCodeApi.kt. +- No overloads added and no callers changed/commented out. + +Compile verification: +- export JAVA_HOME=/usr/lib/jvm/java-17-openjdk && ./gradlew :app:compileDebugKotlin +- Result: BUILD SUCCESSFUL. Actual failing files: none. + +Additional test compile check: +- export JAVA_HOME=/usr/lib/jvm/java-17-openjdk && ./gradlew :app:compileDebugUnitTestKotlin +- Result: BUILD SUCCESSFUL. Actual failing test files: none. + +Mismatch vs original acceptance: the ticket expected a red tree, but current call sites already pass directory explicitly or route through WorkspaceClient, so removing defaults did not produce compile failures. diff --git a/.tickets/oa-pecx.md b/.tickets/oa-pecx.md new file mode 100644 index 00000000..51aa916a --- /dev/null +++ b/.tickets/oa-pecx.md @@ -0,0 +1,15 @@ +--- +id: oa-pecx +status: closed +deps: [] +links: [] +created: 2026-03-05T19:51:08Z +type: feature +priority: 2 +assignee: Jasmin Le Roux +tags: [ui, sessions] +--- +# Expand session context menu + +Session long-press menu currently only has Delete. Expand to include: Rename, View Changes, Share, Revert. Use TuiDropdownMenuItem for consistent styling. Add divider before destructive Delete action. + diff --git a/.tickets/oa-plno.md b/.tickets/oa-plno.md new file mode 100644 index 00000000..a9e0deb8 --- /dev/null +++ b/.tickets/oa-plno.md @@ -0,0 +1,35 @@ +--- +id: oa-plno +status: open +deps: [] +links: [oa-ua2q, oa-12ui] +created: 2026-07-05T18:06:47Z +type: bug +priority: 2 +assignee: Jasmin Le Roux +parent: oa-nwha +--- +# Restore session list search and tree expansion state + +Problem: +Session list search text and tree expansion state may be lost or leak because they are lifecycle-blind or not clearly scoped. + +Evidence: +Lifecycle audit identified SessionListScreen.kt and SessionListViewModel.kt search and tree expansion state as restoration-critical for navigating sessions. + +UX Constraint: +Session navigation must remain compact but predictable. Losing search/expansion while switching tabs can make it hard to resume work and can increase wrong-session selection risk. + +Expected Behavior: +Session list search and tree expansion restore for the same workspace/server context and do not leak across different workspaces/servers. Clear actions should explicitly reset state. + +Acceptance Criteria: +- Scope search query and tree expansion by workspace/server/tab context. +- Preserve state across tab switch and configuration recreation. +- Avoid leaking one workspace/server's expanded tree into another. +- Add tests for same-context restoration and different-context isolation. +- Define behavior when restored search no longer matches any sessions. + +Verification: +Run targeted SessionListViewModel/Screen tests and smoke test switching between two workspace session lists. + diff --git a/.tickets/oa-poi7.md b/.tickets/oa-poi7.md new file mode 100644 index 00000000..8aebfa6f --- /dev/null +++ b/.tickets/oa-poi7.md @@ -0,0 +1,42 @@ +--- +id: oa-poi7 +status: closed +deps: [] +links: [] +created: 2026-07-05T18:04:23Z +type: bug +priority: 1 +assignee: Jasmin Le Roux +parent: oa-nwha +--- +# Eliminate workspace null and global fallback leakage + +Problem: +Workspace scoping audit found remaining null/global fallback leaks after the workspace/session cutover. These can reintroduce wrong-directory and multi-tab ambiguity even though the worst forbidden patterns are absent. + +Evidence: +Positive findings: no Workspace.DEFAULT or Workspace.global(server), no app-global CurrentWorkspace singleton, no data-layer active-tab workspace access, no nullable withWorkspace escape hatch, no parallel chat message buffer, and OpenCodeApi directory params do not appear to use = null defaults. Remaining concerns include SessionRepositoryImpl.searchSessions(query, directory: String? = null) treating null as global plus every project worktree, scoped SessionRepositoryImpl.hydrate() still loading global and all project sessions, tests encoding broad null-directory search, createTab(... workspaceDirectory: String? = null) allowing global tabs by omission, and TabState defaulting workspaceDirectory to null. + +UX Constraint: +Workspace/project identity must be visible enough to prevent wrong-directory mistakes, but persistent chrome should remain compact. Multi-tab behavior must never guess a directory from stale/global context. + +Expected Behavior: +Every session, file, command, terminal, and tab operation uses the workspace owned by that tab/server. Server-global behavior is explicit and intentionally represented by a scoped null directory only when that is the selected workspace, not by omitted parameters or fallback chains. + +Acceptance Criteria: +- Remove nullable default arguments that let callers omit workspace identity from repository/tab APIs. +- Make global/server-wide session search explicit in type/name/UI and separate from workspace-scoped search. +- Ensure SessionRepositoryImpl scoped hydration only hydrates the current workspace unless explicitly asked for server-global search. +- Require tab creation callers to pass an explicit workspace choice or route through a workspace-selection flow. +- Update tests that encode broad null-directory search to assert explicit scoped/global behavior. +- Add regression tests for two tabs on different workspaces showing isolated session/search results. + +Verification: +Run targeted repository/tab manager/session list tests. Smoke test creating tabs for two directories and listing/searching sessions without cross-contamination. + + +## Notes + +**2026-07-05T18:05:36Z** + +Superseded by narrower workspace tickets for null-directory session search leakage and tab creation null-workspace defaults. The broad workspace category hid two different fixable behaviors. diff --git a/.tickets/oa-prjv.md b/.tickets/oa-prjv.md new file mode 100644 index 00000000..d9a8ce05 --- /dev/null +++ b/.tickets/oa-prjv.md @@ -0,0 +1,35 @@ +--- +id: oa-prjv +status: open +deps: [] +links: [oa-wmvc, oa-12ui] +created: 2026-07-05T18:06:47Z +type: bug +priority: 2 +assignee: Jasmin Le Roux +parent: oa-nwha +--- +# Resource MCP and skills status display text + +Problem: +MCP and skills status display text is mapped to English strings before or outside the proper UI/resource boundary. + +Evidence: +Display-boundary audit identified SkillsScreen.kt and MCP/skills status mappings as user-visible status text that may be hardcoded outside resources. + +UX Constraint: +MCP/skills state must be understandable without crowding the agent workspace. Error, unavailable, loading, and ready states should use consistent app-wide status language and accessible descriptions. + +Expected Behavior: +MCP/skills domain or integration layers expose structured status codes and details. UI maps those statuses to resource-backed labels, descriptions, and status indicators. + +Acceptance Criteria: +- Inventory MCP/skills status strings and distinguish protocol identifiers from user-facing labels. +- Move user-facing status labels/descriptions to resources or a centralized UI formatter. +- Preserve raw server/tool identifiers only as labeled technical metadata. +- Add tests for known status mappings and unknown/error fallback text. +- Ensure status indicators have meaningful content descriptions where functional. + +Verification: +Run targeted Skills/MCP formatter or screen tests and compile after implementation. + diff --git a/.tickets/oa-puhk.md b/.tickets/oa-puhk.md new file mode 100644 index 00000000..c5b5a563 --- /dev/null +++ b/.tickets/oa-puhk.md @@ -0,0 +1,40 @@ +--- +id: oa-puhk +status: closed +deps: [] +links: [] +created: 2026-05-10T09:41:44Z +type: bug +priority: 1 +assignee: Jasmin Le Roux +--- +# Remove TerminalView reference from TerminalViewModel + +Problem: +TerminalViewModel stores a WeakReference and directly calls postInvalidate() on the Android View. This crosses the ViewModel/UI boundary and can miss invalidations when Compose recreates the AndroidView or when tabs/backgrounding detach the view. + +Evidence: +app/src/main/java/dev/blazelight/p4oc/ui/screens/terminal/TerminalViewModel.kt has terminalViewRef: WeakReference?, attachTerminalView(), clearTerminalView(), and multiple terminalViewRef?.get()?.postInvalidate() calls. The comment at onTextChanged says the view is invalidated directly via postInvalidate. + +UX Constraint: +Terminal rendering must stay responsive across tab switches, recomposition, background/foreground, and terminal input/output without leaking or retaining Android Views from a ViewModel. + +Expected Behavior: +TerminalViewModel exposes an invalidation signal as data/events. The Compose/AndroidView layer owns the TerminalView instance and collects the signal to call postInvalidate() on the current attached view. + +Acceptance Criteria: +- Remove WeakReference and TerminalView imports from TerminalViewModel. +- Expose a SharedFlow or equivalent one-shot invalidation event from TerminalViewModel. +- TermuxTerminalView.kt or the AndroidView owner collects invalidation events and invalidates the current view instance. +- Preserve all current terminal output/input behavior and emulator state ownership. +- Add or update focused tests where practical for invalidation signal emission. + +Verification: +Run ./gradlew :app:compileDebugKotlin and manually verify terminal output updates after switching away from and back to a terminal tab. + + +## Notes + +**2026-05-10T11:08:21Z** + +Removed TerminalView ownership from TerminalViewModel. ViewModel now exposes terminalInvalidations SharedFlow and accepts measured terminal row/col changes; Compose/AndroidView layer owns TerminalView invalidation and measurement. Verified with ./gradlew :app:compileDebugKotlin. diff --git a/.tickets/oa-q2x1.md b/.tickets/oa-q2x1.md new file mode 100644 index 00000000..cf71cf63 --- /dev/null +++ b/.tickets/oa-q2x1.md @@ -0,0 +1,24 @@ +--- +id: oa-q2x1 +status: closed +deps: [] +links: [] +created: 2026-05-09T15:44:01Z +type: feature +priority: 2 +assignee: Jasmin Le Roux +--- +# Add create-new-session button to project session list + +Inside a selected project/workspace, the session list should provide an obvious create-new-session action. Users should not have to back out, use slash commands, or infer another control to start a fresh session within the current project.\n\nExpected UX:\n- When viewing the session list for a project/workspace, show a clear New Session / + button.\n- The action creates a session scoped to the current workspace directory.\n- The new session opens immediately in the current tab/project context.\n- The button should be available in normal and empty-list states.\n\nAcceptance criteria:\n- Project-scoped session list has a visible create-new-session button.\n- Empty project/session-list state includes a create-new-session CTA.\n- Created session uses the current tab/workspace directory; no global/default workspace fallback.\n- UI follows existing theme tokens and accessibility conventions, including content description/test tag for the interactive control. + + +## Notes + +**2026-05-09T15:52:46Z** + +Standardization note: create-new-session control must be project/workspace scoped and space-efficient. It should be obvious in the project session list and empty state, but should not add persistent chrome inside the chat/agent transcript. Prefer a compact '+'/New Session action in the list header or empty state. Acceptance must verify no global/default workspace fallback and immediate navigation to the new session in the current project context. + +**2026-05-10T12:34:02Z** + +Implemented a project-scoped New Session action in filtered session lists and kept the empty-state CTA path covered by the same visible action. The action uses the filtered project's worktree and existing workspace-switch/autocreate flow, then opens the created session via onNewSession. Verified with ./gradlew :app:compileDebugKotlin. diff --git a/.tickets/oa-qr52.md b/.tickets/oa-qr52.md new file mode 100644 index 00000000..77d32da0 --- /dev/null +++ b/.tickets/oa-qr52.md @@ -0,0 +1,20 @@ +--- +id: oa-qr52 +status: closed +deps: [oa-7ysx, oa-vvep, oa-0f4m, oa-cemz, oa-blgp, oa-ww0m] +links: [] +created: 2026-05-01T17:45:54Z +type: task +priority: 1 +assignee: Jasmin Le Roux +parent: oa-gt0g +tags: [workspace, primitives] +--- +# Commit 1: ADD workspace/session/path primitives + +Add domain/workspace/{Workspace,WorkspacePath,AttachmentRef}.kt and domain/session/{SessionId,WorkspaceSession}.kt. Add data/server/{ActiveServerApiProvider,ServerEventGateway}.kt. Add data/workspace/WorkspaceClient.kt (wraps OpenCodeApi, bakes directory in). Add data/session/{SessionRepository interface, SessionRepositoryImpl skeleton, SessionReducer}.kt. No usages yet — must compile standalone. + +## Acceptance Criteria + +1) All files exist in target packages per plan. 2) RelativePath constructor rejects blank/absolute/file://. 3) Workspace has NO companion object with default values (no Workspace.global / .DEFAULT / .current). 4) WorkspaceClient has 'val workspace' (immutable), no 'var workspace'. 5) Unit tests on RelativePath, WorkspacePath, AttachmentRef pass. 6) ./gradlew :app:compileDebugKotlin green. + diff --git a/.tickets/oa-qu7u.md b/.tickets/oa-qu7u.md new file mode 100644 index 00000000..edfd0ea2 --- /dev/null +++ b/.tickets/oa-qu7u.md @@ -0,0 +1,26 @@ +--- +id: oa-qu7u +status: closed +deps: [] +links: [] +created: 2026-05-05T17:48:39Z +type: feature +priority: 1 +assignee: Jasmin Le Roux +parent: oa-ssm2 +tags: [files, viewer, phase-1] +--- +# Tier A: wrap viewer body in SelectionContainer (selectable file text) + +Wrap only the body Text inside SyntaxHighlightedCode (SyntaxHighlighter.kt:501-513) in a SelectionContainer so users can select and copy file body text in FileViewerScreen. Add 'selectable: Boolean = false' parameter; default false so chat code blocks are unaffected; pass true from FileViewerScreen.kt:94. Do NOT wrap line-number gutter. User quote: 'A smaller enhancement is select / copy text from file. ... for setting a secret I need to use the agent explaining the exact line to be changed.' + +## Acceptance Criteria + +Long-press on file body in viewer offers system Copy. Line numbers do not enter the selection. Chat code blocks unchanged (no inadvertent selection in tool widgets / diff viewers). Manual test on phone: select 'API_KEY=...' from a .env-like file, copy, paste into another app. + + +## Notes + +**2026-05-05T18:41:38Z** + +Implemented. SyntaxHighlighter.kt — added selectable: Boolean = false param to SyntaxHighlightedCode. When true, body Text wrapped in SelectionContainer (line-number gutter outside). FileViewerScreen.kt — passes selectable = true. CodeSnippet untouched, defaults to false (chat code blocks unaffected). Build green. diff --git a/.tickets/oa-qv8d.md b/.tickets/oa-qv8d.md new file mode 100644 index 00000000..f97abfb3 --- /dev/null +++ b/.tickets/oa-qv8d.md @@ -0,0 +1,33 @@ +--- +id: oa-qv8d +status: open +deps: [] +links: [] +created: 2026-07-06T12:52:06Z +type: task +priority: 1 +assignee: Jasmin Le Roux +parent: oa-nwha +--- +# Decide plus-button workspace semantics + +Problem: +Implementation of oa-e6g3 was paused because the WorkspaceKey migration affects tab bar plus-button semantics. A mechanical migration could accidentally change plus-created Sessions/Terminal tabs from active-context siblings into always-Global tabs. + +Evidence: +Brainstorm on 2026-07-06 converged that plus-button behavior is a product-intent decision, not just a storage refactor. Current code inherits active tab workspace for Sessions/Terminal; ticket notes contain the full decision pause. + +Expected Behavior: +Before oa-e6g3 is implemented, choose and document the desired plus-button policy for Sessions, Terminal, Files, contextual opens, Global, Directory, SessionScoped, and legacy/missing workspace states. + +Verification: +No production implementation required; verify by updating oa-e6g3 design notes/acceptance with the chosen policy and unblocking the ticket. + +## Acceptance Criteria + +- Decide whether plus-created Sessions and Terminal inherit the active tab WorkspaceKey or open as explicit Global. +- Decide Files plus-menu behavior: chooser, inherit, or Global. +- Decide how legacy/missing workspace tabs are recovered in UI. +- Update oa-e6g3 notes/acceptance with the chosen policy. +- Unblock oa-e6g3 after the decision is recorded. + diff --git a/.tickets/oa-qy0f.md b/.tickets/oa-qy0f.md new file mode 100644 index 00000000..057df4e8 --- /dev/null +++ b/.tickets/oa-qy0f.md @@ -0,0 +1,103 @@ +--- +id: oa-qy0f +status: closed +deps: [] +links: [oa-nwha, oa-12ui, oa-casy, oa-3yk2, oa-wmvc, oa-wxf2, oa-dygk] +created: 2026-07-05T16:59:51Z +type: bug +priority: 1 +assignee: Jasmin Le Roux +parent: oa-casy +--- +# Fix model agent defaults and config refresh contracts + +Problem: +Model/agent selection mixes Android-side fallbacks with upstream/server defaults and does not refresh active chat state after settings writes. Red tests should lock the intended source-of-truth contract before production changes. + +Evidence: +- ModelAgentManager.kt:62-72 filters to primary agents and falls back to persisted agent, agent named build, then first primary agent. +- ModelAgentManager.kt:115-130 selects models from recents/defaults during one-shot loadModels. +- ProviderConfigViewModel.kt:71-82 writes api.updateConfig(model = provider/model) but only updates its own UI state. +- ChatViewModel.kt:168-173 creates ModelAgentManager once and calls loadAgents/loadModels at init. +- ChatViewModel.kt:312-324 sends messages using modelAgentManager.selectedModel.value. +- ModelControlsScreen.kt:123-136 optimistically updates selected model and ignores the ApiResult from api.setActiveModel. +- Contract audit found current tests blur good server-default contracts with risky implementation policy such as hardcoded build and first reasoning variant. + +UX Constraint: +Users must be able to trust that selected/default model and agent shown in chat match the server/config or their explicit per-chat choice. Settings must not show success or let active chats continue stale model choices after writes. + +Expected Behavior: +Server/config defaults are authoritative when no explicit per-chat user override exists. User explicit choices are preserved and validated against current provider/agent data. Recents are a convenience fallback only when no upstream/default choice exists. Reasoning variants are not inferred from collection order unless upstream exposes an explicit default. Active chat state refreshes or reconciles after successful config/model writes. + +## Design + +Separate selection sources explicitly: upstream/server default, explicit user override, persisted per-session override, recent convenience fallback, and no selection/server-decides. Prefer a shared observable model/config source over one-shot loads. Avoid sending model/variant fields when the intended behavior is to let the server decide. + +## Acceptance Criteria + +- Add failing red tests proving server/provider default model wins over stale recent model unless the user made an explicit per-chat override. +- Add failing red tests proving reasoning effort/variant is not inferred from first available variant unless an explicit upstream/user default exists. +- Add failing red tests for ProviderConfigViewModel or shared repository behavior where changing default model refreshes/reconciles an active ChatViewModel/ModelAgentManager. +- Add failing red tests for ModelControls selectModel failure rollback or error handling; optimistic UI cannot swallow failed writes. +- Production fix removes hardcoded build as an unconditional default unless upstream config names it explicitly. +- Verification: run targeted ModelAgentManager/ProviderConfig/ModelControls tests and ./gradlew :app:compileDebugKotlin. + + +## Notes + +**2026-07-05T18:05:19Z** + +Broader model/config audit findings folded into this ticket on 2026-07-05: + +Keep this as the cohesive fix for model/agent defaults, stale runtime after config writes, and ModelControls optimistic-write failure handling. Do not create separate duplicate tickets for provider default refresh or ModelControls rollback unless implementation later proves they need independent sequencing. + +Additional evidence/scope: +- ProviderConfigViewModel writes provider default model through api.updateConfig but only updates its own UI state. +- ChatViewModel creates ModelAgentManager once and sends messages using selectedModel from that manager, so active chats can remain stale after config/settings writes. +- ModelControlsScreen optimistically changes selected model and ignores failed setActiveModel ApiResult. +- SettingsDataStore/favorites/recent model state can diverge from local UI state if not observed as source of truth. +- Connection/reconnect settings sampled during active windows should be intentionally sampled or observed; do not let runtime silently diverge. + +Acceptance addendum: +- Active chat must either refresh/reconcile when provider/model/agent defaults change or clearly indicate changes apply only to future chats. +- setActiveModel failure must preserve previous selected state and surface a human-readable error. +- Duplicate local model/favorite/recent state should be removed or made derived from the authoritative flow. + +**2026-07-06T10:10:42Z** + +Progress update from 2026-07-06: + +Implemented and verified the mapped red failure for server/provider default precedence in ModelAgentManager.loadModels. Selection now chooses an available server default before falling back to available recent models. Targeted ModelAgentManagerTest passed, and the focused red suite dropped from 7 failures to 6: `ModelAgentManagerTest > loadModels prefers server default over app recent model` now passes. + +Verification run: +- JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew :app:testDebugUnitTest --tests dev.blazelight.p4oc.ui.screens.chat.ModelAgentManagerTest -> PASS +- JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew :app:compileDebugKotlin -> PASS +- JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew :app:detekt -> PASS after line-length/import-order cleanup in red tests +- Focused four-complaint red suite still fails intentionally with 6 remaining failures outside this fixed model-precedence assertion. + +Do not close oa-qy0f yet. The ticket still has broader unmet acceptance: ProviderConfig/shared refresh behavior, ModelControls setActiveModel failure rollback/error handling, and hardcoded build-as-agent-default policy need tests and production fixes. + +**2026-07-06T10:35:21Z** + +Completion update from 2026-07-06: + +Completed the full model/config defaults and refresh contract. + +Production changes: +- ModelAgentManager no longer hardcodes `build` as an unconditional default agent; persisted session agent still wins, otherwise server order chooses the first primary non-hidden agent. +- ModelAgentManager tracks explicit user model selection separately from agent-provided/default selection. Server/provider default now wins over stale recents when there is no explicit override; recents are only fallback. Explicit still-available user model choices survive reload; unavailable explicit choices reconcile to server default/fallback. +- Added ModelSelectionCoordinator as the shared active-model refresh seam. Koin provides a singleton and wires it to ChatViewModel, ModelControlsViewModel, and ProviderConfigViewModel. +- ModelControlsViewModel only updates selectedModelId after successful setActiveModel(true), rolls back/preserves previous selection on false/error/no API/missing model, surfaces human-readable errors, and publishes successful active model changes. +- ProviderConfigViewModel updates currentModel only after updateConfig succeeds, preserves previous state on failure, clears error on success, and publishes successful provider/model config changes. +- Chat ModelAgentManager instances collect coordinator activeModelChanges and reconcile selectedModel when no explicit user or agent model override is active. + +Tests added/updated: +- ModelAgentManagerTest covers server default vs recents, no reasoning-effort inference, server-order agent default instead of hardcoded build, explicit model preservation/reconciliation, and coordinator publish -> selectedModel reconciliation with explicit/agent guard cases. +- ModelControlsViewModelTest covers success, false success rollback, API error rollback, no API/missing model rollback, and coordinator publishing only after successful API update. +- ProviderConfigViewModelTest covers no optimistic currentModel update, failure preservation/error, and coordinator publishing only after successful updateConfig. + +Verification: +- ./gradlew :app:testDebugUnitTest --tests dev.blazelight.p4oc.ui.screens.chat.ModelAgentManagerTest --tests dev.blazelight.p4oc.ui.screens.settings.ModelControlsViewModelTest --tests dev.blazelight.p4oc.ui.screens.settings.ProviderConfigViewModelTest -> PASS +- ./gradlew :app:compileDebugKotlin -> PASS +- ./gradlew :app:detekt -> PASS +- ./gradlew :app:testDebugUnitTest -> expected FAIL from six remaining red tests mapped to oa-wmvc, oa-wxf2, and oa-3yk2; no oa-qy0f failures remained. diff --git a/.tickets/oa-r0sq.md b/.tickets/oa-r0sq.md new file mode 100644 index 00000000..b6662a59 --- /dev/null +++ b/.tickets/oa-r0sq.md @@ -0,0 +1,40 @@ +--- +id: oa-r0sq +status: open +deps: [] +links: [] +created: 2026-05-10T16:27:28Z +type: task +priority: 2 +assignee: Jasmin Le Roux +--- +# Investigate slash input interaction model + +Problem: +Slash autocomplete and slash command execution need a deliberate interaction model. The current implementation is functional enough for testing, but recent iteration exposed ambiguous UX/architecture choices around popup anchoring, command insertion vs execution, loading states, and command palette overlap. + +Evidence: +- Inline layout pushed agent/model controls upward, which is not acceptable for chat chrome density. +- Input-local overlay could cover the text field or become constrained/tiny depending on parent measurement. +- A top-level Popup with a position provider works better for z-order and layout isolation, but should be validated across IME, rotation, small screens, and attachment rows. +- Selecting a slash suggestion currently inserts /command plus a trailing space for optional args; sending /command routes to executeCommand rather than normal chat. +- Built-in commands are merged with API commands because listCommands does not return OpenCode built-ins. + +UX Constraint: +The popup must be compact, one-line per row, transient, and must not consume persistent chat space. It must not cover the typed command/cursor and must not push agent/model controls or other chat chrome. The user should be able to scroll the full matching command list with no artificial item cap. + +Expected Behavior: +Typing / opens a compact command menu anchored above the chat input. The popup overlays at top z-level, remains linked to the input position, handles IME/window insets correctly, and shows all matches in a bounded scrollable list. Selecting a command should either insert /command with cursor at the end for arguments or execute immediately, based on a clearly chosen rule per command type. + +Acceptance Criteria: +- Decide and document whether slash suggestions are insertion-only, immediate execution, or command-type dependent. +- Decide how built-in commands, MCP commands, custom commands, skills, and subtasks should be labeled and executed. +- Validate popup positioning with IME open, attachments present, portrait/landscape, and small phone widths. +- Verify the popup never pushes agent/model controls and never covers the typed command/cursor. +- Verify full result scrolling without synthetic caps. +- Identify whether command palette and slash autocomplete should share filtering/source-label helpers. +- Capture failure states for command loading with human-readable errors and no raw protocol payloads. + +Verification: +Manual emulator/device testing should include typing /, filtering to an empty state, scrolling a long list, selecting a command with args, executing /compact, and retrying command loading failure. + diff --git a/.tickets/oa-r8yn.md b/.tickets/oa-r8yn.md new file mode 100644 index 00000000..051a9495 --- /dev/null +++ b/.tickets/oa-r8yn.md @@ -0,0 +1,20 @@ +--- +id: oa-r8yn +status: closed +deps: [] +links: [] +created: 2026-05-09T15:57:46Z +type: feature +priority: 2 +assignee: Jasmin Le Roux +--- +# Add status dot legend to Settings Help + +Problem:\nStatus dots and run indicators appear across tabs, sessions, sub-agents, chat, files, and connection UI, but users do not have a single in-app explanation of what the colors/motion mean.\n\nEvidence:\nCurrent code uses dots/spinners in multiple places including chat connection state, tab state, session list state, and dirty file title markers. Semantics are partially centralized but still not exposed to users.\n\nUX Constraint:\nDo not add persistent explanatory chrome to agent/chat/file surfaces. The legend belongs in Settings -> Help so it explains the system without consuming workspace space.\n\nExpected Behavior:\nSettings -> Help includes a concise status legend explaining connected/idle, running/busy, awaiting user input, retrying/reconnecting, error, background/cold, and dirty/unsaved states.\n\nAcceptance Criteria:\n- Settings -> Help includes a status indicator legend.\n- Legend matches the centralized status dot semantics in AGENTS.md and app code.\n- Running/busy uses real run state only; no fake percentages.\n- Awaiting-user state is distinguishable from generic running.\n- Dirty/unsaved file marker is documented.\n- The legend does not add persistent UI chrome to chat/session/file screens.\n\nVerification:\n- Open Settings -> Help and verify each status state is explained.\n- Verify labels/colors match the current theme/state mapping. + + +## Notes + +**2026-05-10T11:24:21Z** + +Implemented Settings > Help status indicator legend with entries for connected/idle, running/busy, awaiting user input, retrying/reconnecting, error, background/cold, and dirty/unsaved. Legend uses existing theme status colors and does not add persistent chrome to chat/session/file screens. Verified with export JAVA_HOME=/usr/lib/jvm/java-17-openjdk && ./gradlew :app:compileDebugKotlin. diff --git a/.tickets/oa-rde5.md b/.tickets/oa-rde5.md new file mode 100644 index 00000000..bfe08fb4 --- /dev/null +++ b/.tickets/oa-rde5.md @@ -0,0 +1,40 @@ +--- +id: oa-rde5 +status: closed +deps: [oa-p98b, oa-ja73, oa-vvep, oa-0f4m] +links: [] +created: 2026-05-01T17:45:54Z +type: task +priority: 1 +assignee: Jasmin Le Roux +parent: oa-gt0g +tags: [workspace, sessions, repo] +--- +# Commit 5 (reordered): SessionListViewModel + SessionRepositoryImpl with ported behavior + +Reordered before ChatVM rewrite — Chat depends on SessionRepository.messages(WorkspaceSession). Implement SessionRepositoryImpl: port Semaphore(10) bounded concurrency from SessionDataCache, port 30s freshness, port per-project fan-out + global/project dedupe, port stale-server discard, port prewarm. Implement reducer hydrate-then-stream per design-B and optimistic rollback per design-C. Rewrite SessionListViewModel scoped to Workspace, mutations via WorkspaceClient. DELETE SessionDataCache.kt. Tree still red. + +## Acceptance Criteria + +1) SessionRepositoryImpl.hydrate uses Semaphore(10) literally (or equivalent), verified by grep + behavior test. 2) 30s freshness window present. 3) Optimistic mutation test: mock HTTP 5xx on delete → item reappears with error (per design-C). 4) Hydrate-race test: SSE event during hydrate appears in final ordered state, not lost (per design-B). 5) SessionDataCache.kt does NOT exist (file deleted). 6) No 'directory ?: directoryManager.getDirectory()' fallback. 7) Existing SessionDataCacheTest behavior is REPLACED, not deleted (test ported to SessionRepositoryImplTest). 8) ./gradlew :app:testDebugUnitTest green for these tests. + + +## Notes + +**2026-05-02T11:53:24Z** + +Implemented SessionRepositoryImpl + SessionListViewModel rewrite. + +Summary: +- Ported SessionDataCache behavior into SessionRepositoryImpl: Semaphore(10), 30s freshness, in-flight prewarm dedupe, global/project fan-out, project/global session dedupe, status hydrate, hydrate event replay, and optimistic delete failure refetch. +- Rewrote SessionListViewModel to consume tab-scoped SessionRepositoryImpl instead of ConnectionManager/DirectoryManager/SessionDataCache. +- Wired SessionListScreen through the per-tab WorkspaceViewModel in TabNavHost. +- Deleted SessionDataCache.kt and replaced SessionDataCacheTest with SessionRepositoryImplTest. +- Removed SessionDataCache from DI and ServerViewModel prewarm calls. + +Verification: +- export JAVA_HOME=/usr/lib/jvm/java-17-openjdk && ./gradlew :app:compileDebugKotlin: BUILD SUCCESSFUL +- export JAVA_HOME=/usr/lib/jvm/java-17-openjdk && ./gradlew :app:testDebugUnitTest --tests 'dev.blazelight.p4oc.data.session.*': BUILD SUCCESSFUL +- export JAVA_HOME=/usr/lib/jvm/java-17-openjdk && ./gradlew :app:testDebugUnitTest: BUILD SUCCESSFUL + +Note: original ticket said tree still red, but this implementation currently compiles and tests green. The remaining DirectoryManager fallback grep hit is in ChatViewModel, which is reserved for the later ChatViewModel rewrite ticket. diff --git a/.tickets/oa-rnf3.md b/.tickets/oa-rnf3.md new file mode 100644 index 00000000..7e37438b --- /dev/null +++ b/.tickets/oa-rnf3.md @@ -0,0 +1,24 @@ +--- +id: oa-rnf3 +status: closed +deps: [] +links: [] +created: 2026-05-09T15:47:17Z +type: feature +priority: 2 +assignee: Jasmin Le Roux +--- +# Expose file create and delete actions in file explorer + +File mutation infrastructure exists through OFISH, but the file explorer UI does not expose obvious create/delete workflows. Users should be able to create small files and delete files/folders from mobile without asking the agent to perform the operation.\n\nExpected UX:\n- File explorer provides New file / New folder actions for the current workspace directory.\n- File rows provide Delete through a long-press or overflow menu with confirmation.\n- Empty folders show CTAs such as New file and Upload here.\n- Actions are capability-gated when OFISH/file mutation support is unavailable.\n\nAcceptance criteria:\n- New file creates a file in the current explorer directory using the current Workspace.\n- New folder creates a directory in the current explorer directory, if supported by the mutation backend.\n- Delete removes the selected file/folder only after confirmation.\n- Empty folder state includes create/upload affordances.\n- UI never falls back to a global/default workspace.\n- Failure/conflict/capability-missing states are shown as human-readable messages. + + +## Notes + +**2026-05-09T15:52:42Z** + +Standardization note: deliver the full file mutation UI surface, not only a first slice. Include New file, New folder where backend supports it, Rename if feasible, Delete with confirmation, and empty-folder CTAs (New file, Upload here). Long-press/overflow is the correct contextual surface for row actions; creation should also be visible in top-bar/empty state. All actions must be current-Workspace scoped, capability-gated, and show human-readable failure/conflict/capability-missing messages. UI chrome must be justified: prefer contextual menus and empty-state CTAs over persistent buttons that reduce file/agent viewport. + +**2026-05-10T14:42:12Z** + +Implemented workspace-scoped file explorer mutation UI: top-bar New file/New folder menu, empty-folder New file/Upload here CTAs, long-press row Rename/Delete actions with delete confirmation, capability gating, and human-readable mutation errors. Extended FileRepository/OFISH with createDirectory and renameFile, enabled recursive delete for folders, and added focused OFISH command/client test coverage. Verification: :app:compileDebugKotlin passes. :app:detekt still fails on existing non-ticket findings outside this work after new touched-file findings were addressed. Targeted OFISH unit test run is blocked by an existing ChatViewModelTest constructor mismatch unrelated to this ticket. diff --git a/.tickets/oa-ryve.md b/.tickets/oa-ryve.md new file mode 100644 index 00000000..6b792ac8 --- /dev/null +++ b/.tickets/oa-ryve.md @@ -0,0 +1,26 @@ +--- +id: oa-ryve +status: closed +deps: [] +links: [] +created: 2026-05-05T17:48:39Z +type: feature +priority: 1 +assignee: Jasmin Le Roux +parent: oa-ssm2 +tags: [files, ofish, phase-3] +--- +# OFISH capability probe + AppShellSessionProvider + permission broker + +Capability probe: on first WorkspaceClient connect, run a probe shell command that emits OFISH/1 caps=base64=...,hash=...,mv,mkdir,rm and exit code. Cache result on WorkspaceClient (per-workspace, per-generation — AGENTS.md compliance). If caps missing, FileRepository.write/delete/upload throw a typed CapabilitiesUnavailable error and UI disables Save/Delete/Upload with a banner explaining why. AppShellSessionProvider: lazily creates and caches a per-workspace session titled '__opencode_app_fileops__' for app-driven shell. NEVER use the user's visible chat session (executeShellCommand returns a MessageWrapperDto that becomes a real chat message — this would pollute history with base64 blobs). Session is filtered out of visible session list (client-side title prefix filter until server adds metadata). Permission broker: when an app shell command emits '### op=' marker, correlate incoming OpenCodeEvent.PermissionRequested by op-id; if it matches a user-initiated Save/Delete/Upload, auto-reply 'once'. NEVER auto-reply 'always'. Mismatches surface a confirmation dialog. Falls back to one-prompt-per-session if op-id correlation isn't available. + +## Acceptance Criteria + +First-connect probe completes, caps cached. Hidden session created on first file op, reused thereafter. Hidden session does not appear in user's session list (or does, with clear marker label, until filter API exists). Op-id correlated permissions auto-approve only matching ops. Layer 2 tests cover: probe parsing for GNU/macOS/BSD, missing tool detection, op-id mismatch flow. + + +## Notes + +**2026-05-05T17:55:45Z** + +Closed: superseded by rewritten ticket. Original spec referenced an app-generated op-id and AppShellSessionProvider; revised plan reuses server-issued callID via existing pendingPermissionsByCallId, and renames the session helper to OfishSessionProvider. See /tmp/opencode-signoff/file-ops-signoff.html §5. diff --git a/.tickets/oa-s5jj.md b/.tickets/oa-s5jj.md new file mode 100644 index 00000000..84429988 --- /dev/null +++ b/.tickets/oa-s5jj.md @@ -0,0 +1,40 @@ +--- +id: oa-s5jj +status: closed +deps: [oa-p829] +links: [] +created: 2026-05-05T17:57:03Z +type: feature +priority: 1 +assignee: Jasmin Le Roux +parent: oa-ssm2 +tags: [files, ofish, phase-4] +--- +# OfishCommandBuilder + OfishFileRepository (heredoc-stdin payloads) + Layer 1+2 tests + +Implement OFISH command generation. OfishCommandBuilder is a pure function (op, path, content?, expectedHash?) -> String. Each command starts with '#OFISH_ path=... expected=...' marker (a shell comment that's harmless to POSIX shell but recognizable to a future server-side fast-path matcher), followed by the equivalent POSIX shell body. This is the FISH lesson lifted literally: same wire format covers slow-path (shell) and fast-path (native) eras — there is NO second repository implementation. + +Wire format details: +- Single-quote escaping for paths ('\''-style); reject empty/absolute/'..' (also enforced in FileRepository per Phase 2). +- 'set -efu', trap-cleanup for temp files, mkdir -p for parents, atomic mv -f -- in same directory (NOT /tmp — cross-FS rename loses atomicity). printf '%s' not echo. +- Base64 payload fed via HEREDOC ON STDIN ("base64 -d > \"$TMP\" <<'__OFISH_B64__'"), NOT via argv. This sidesteps argv length limits entirely and lets us use larger chunks. +- Heredoc terminator includes a session-random suffix per command (defense-in-depth against payload collision; base64 alphabet excludes '_' anyway). +- Reply parser anchors on '^### \d{3}' (FISH-style FTP-superset trailer): 200 ok, 201 created, 204 deleted, 404 missing, 409 conflict actual=..., 412 precondition, 413 too_large (client-side guard only), 500 error msg=..., 501 caps_missing. +- Conflict and other expected outcomes return shell exit 0 with a non-2xx '### NNN' line. Exit non-zero is reserved for shell internal failures. + +Chunked upload: three commands #OFISH_UPLOAD_INIT / #OFISH_UPLOAD_CHUNK n=i / #OFISH_UPLOAD_FINISH. 256 KiB raw per chunk (~342 KiB base64). Hard cap 16 MiB total — refuse with '### 413 too_large' client-side before sending. OFISH session bloats with each command; opportunistically clear or rotate the session above a size threshold. + +Crash recovery: orphaned .ofish.upload-.partial files cleaned during next capability probe. + +OfishFileRepository wires the builder through OfishSessionProvider + WorkspaceClient.executeShellCommand. Replaces previously-closed oa-e07q which assumed argv-bounded 64 KiB chunks and a dual-impl architecture. + +## Acceptance Criteria + +All Layer 1+2 tests pass on Linux + macOS CI. Manual phone test: write a small text file, read back, hash matches; conflict on stale-hash overwrite triggers conflict dialog; delete works; chunked write of a 5 MiB file completes. No regressions in existing FilesViewModel read flow. OFISH marker comments visible in command body (verify by inspection of OFISH session). + + +## Notes + +**2026-05-05T18:19:53Z** + +Per council directive 3 + 5: drop the 16 MiB hard cap. Empirical chunk-size benchmark (separate ticket) produces a constant; runtime probe at workspace connect halves only if constant fails — never caps upward. SSE replay rationale dropped (ephemeral sessions are never reconnected to). diff --git a/.tickets/oa-ssm2.md b/.tickets/oa-ssm2.md new file mode 100644 index 00000000..a7ed9f9f --- /dev/null +++ b/.tickets/oa-ssm2.md @@ -0,0 +1,25 @@ +--- +id: oa-ssm2 +status: closed +deps: [oa-n86n] +links: [] +created: 2026-05-05T17:47:36Z +type: epic +priority: 1 +assignee: Jasmin Le Roux +tags: [files, architecture, ofish, fish-inspired] +--- +# File ops on Android via OFISH (shell-based) protocol + +Deliver file create / write / delete / upload + select-and-copy on Android using the existing POST /session/{id}/shell endpoint behind a clean FileRepository abstraction. Inspired by the FISH protocol (Files transferred over SHell). Two-week client-only plan; zero server changes blocking. Migration to native server endpoints (when they land) is a drop-in DI swap. Sign-off doc: /tmp/opencode-signoff/file-ops-signoff.html + +## Acceptance Criteria + +All sub-tickets closed. App compiles green. User can: select/copy file body text, create new files, edit and save with hash-guarded conflict detection, delete files, upload device files via SAF. App-driven shell calls live in a dedicated workspace-scoped session, never in user chat. Layer 3 integration tests pass against real opencode serve in CI. + + +## Notes + +**2026-05-07T17:37:11Z** + +Sweep 2026-05-07T17:37Z: all child tickets except oa-n86n are closed. Functional acceptance items satisfied: select/copy text, create+edit+save with hash-guarded conflict, delete, SAF upload, dedicated workspace-scoped OFISH sessions (never chat). Outstanding acceptance: 'Layer 3 integration tests pass against real opencode serve in CI' = oa-n86n. Epic stays open until oa-n86n lands. diff --git a/.tickets/oa-t3tb.md b/.tickets/oa-t3tb.md new file mode 100644 index 00000000..87ebe9a2 --- /dev/null +++ b/.tickets/oa-t3tb.md @@ -0,0 +1,34 @@ +--- +id: oa-t3tb +status: closed +deps: [oa-s5jj] +links: [oa-gtw8, oa-v3js] +created: 2026-05-05T17:57:03Z +type: feature +priority: 1 +assignee: Jasmin Le Roux +parent: oa-ssm2 +tags: [files, editor, phase-5] +--- +# File editor: BasicTextField view/edit toggle + diff-before-save + conflict dialog + +Add view/edit toggle to FileViewerScreen top bar (next to existing line-number toggle at FileViewerScreen.kt:63-77). Edit mode swaps SyntaxHighlightedCode for BasicTextField (or BasicTextField2) with monospace text, IME, scroll-into-view, undo/redo via TextFieldState.undoState. Manual line-number gutter (~30 LOC). Code-context toolbar above keyboard: Tab, {}, (), [], ;. + +NO live syntax highlighting in edit mode (would need VisualTransformation + incremental re-highlight; defer to future). Plain monospace. + +Save flow: 1) compute diff (yours vs server's current content) using a tiny inline Myers diff or java-diff-utils — team to confirm dependency policy; 2) render diff in modal using existing InlineDiffViewer (InlineDiffViewer.kt:172-196); 3) on confirm, call FileRepository.write with expectedHash from initial read; 4) on '### 409 conflict' result, show 3-pane conflict dialog (yours / theirs / common base) — user picks. 5) Never silently overwrite. + +Unsaved-changes back-handler: BackHandler intercepts and shows confirm dialog if dirty. + +Defer to v2: live syntax highlighting in edit mode, autoindent, bracket matching, search-in-file, goto-line, SoraEditor swap. + +## Acceptance Criteria + +Edit toggle works. Typing dirties state. Save shows diff modal first. Stale-hash save shows conflict dialog with current+yours+base. Back with unsaved changes shows confirm. Save+exit reloads viewer with fresh content. No new heavyweight editor library; only BasicTextField + (maybe) diff-utils. + + +## Notes + +**2026-05-05T18:19:53Z** + +Closed: superseded. Council directive 1 picked SoraEditor over BasicTextField — user explicitly prioritized editing UX over APK weight. New ticket follows. diff --git a/.tickets/oa-t4t2.md b/.tickets/oa-t4t2.md new file mode 100644 index 00000000..e826d163 --- /dev/null +++ b/.tickets/oa-t4t2.md @@ -0,0 +1,58 @@ +--- +id: oa-t4t2 +status: closed +deps: [] +links: [oa-a6l7, oa-1n6h, oa-764s, oa-p7ei, oa-es45] +created: 2026-04-19T13:57:04Z +type: feature +priority: 2 +assignee: Jasmin Le Roux +external-ref: pr-3-cherrypick +tags: [perf, benchmark, ci] +--- +# Add :macrobenchmark module with startup, scroll-jank, tab-nav benchmarks + +Establish performance baseline before landing PR A (connect refactor) and PR C (theme preload), so we can measure the actual wins. + +Port the macrobenchmark module from PR #3 branch pr-3 (commits e8d87b5, d2318fb). + +## Files to port from pr-3 + +- macrobenchmark/build.gradle.kts +- macrobenchmark/src/main/AndroidManifest.xml +- macrobenchmark/src/androidTest/java/dev/blazelight/p4oc/benchmark/StartupBenchmark.kt +- macrobenchmark/src/androidTest/java/dev/blazelight/p4oc/benchmark/ScrollJankBenchmark.kt +- macrobenchmark/src/androidTest/java/dev/blazelight/p4oc/benchmark/NavigateTabsBenchmark.kt +- macrobenchmark/src/androidTest/java/dev/blazelight/p4oc/benchmark/GenerateBaselineProfile.kt + +## Additional work not in PR #3 + +- Add `benchmark` build type to app/build.gradle.kts (isDebuggable=false, isMinifyEnabled=true, signingConfig=debug, proguard files) +- Add `profileable android:shell="true"` to AndroidManifest (merged in for benchmark build only) +- Add profileinstaller dep to app module +- baselineprofile plugin + benchmark version entries in gradle/libs.versions.toml +- Register :macrobenchmark in settings.gradle.kts +- Document run command in AGENTS.md: `./gradlew :macrobenchmark:connectedBenchmarkAndroidTest` on POCO X5 5G (ADB 192.168.24.119:47293) + +## Baseline measurements to capture + +Run on main @ current HEAD (918c0d4) before any cherry-picks land: +- Cold startup time (no compilation / partial / baseline profile) +- Tab swipe frame timing +- Scroll jank on chat/sessions list + +Save results to a baselines/ doc so PR A and PR C can compare. + +## Acceptance Criteria + +1. :macrobenchmark module builds (./gradlew :macrobenchmark:assemble) +2. At least StartupBenchmark runs successfully on physical device +3. Baseline numbers captured and committed to docs/ or similar +4. Doc in AGENTS.md on how to run + + +## Notes + +**2026-04-19T16:22:25Z** + +Skipped per user decision — macrobenchmark setup has issues (profileable/debuggable build type, device quirks) that aren't worth solving right now. Wins from PR A (oa-p7ei) and PR C (oa-1n6h) will be validated by manual timing / StrictMode / logs instead of benchmark numbers. diff --git a/.tickets/oa-tk1k.md b/.tickets/oa-tk1k.md new file mode 100644 index 00000000..ec7afadb --- /dev/null +++ b/.tickets/oa-tk1k.md @@ -0,0 +1,33 @@ +--- +id: oa-tk1k +status: closed +deps: [] +links: [] +created: 2026-05-10T09:55:20Z +type: bug +priority: 2 +assignee: Jasmin Le Roux +--- +# Handle missing or invalid restored chat sessions gracefully + +Problem: +Persisted tabs can restore directly to chat routes, including sub-agent sessions. If the session was deleted, archived, or otherwise inaccessible, the tab can land on a dead route instead of recovering to a usable state. + +Evidence: +Sub-agent open-in-new-tab creates a tab with startRoute = Screen.Chat.createRoute(subSessionId) and workspaceDirectory = workspace.directory. Tab state restoration persists startRoute. ChatViewModel loads session by id through repository/workspace client; missing-session behavior needs explicit graceful routing. + +UX Constraint: +Restored tabs should never trap users on a broken chat. Missing sessions should show a human-readable empty/error state with an action to return to the Sessions list or close the tab. + +Expected Behavior: +404/missing getSession or missing messages during restored chat load transitions the tab to a safe screen/state instead of crashing, looping, or leaving permanent loading/error UI. + +Acceptance Criteria: +- Detect missing/inaccessible session during ChatViewModel/session load. +- Surface a concise human-readable message and navigation action, or automatically route to Sessions list when appropriate. +- Preserve valid restored root and sub-agent chat tabs. +- Add tests for restored chat route with missing session returning NotFound/404. + +Verification: +Run ChatViewModel/navigation tests if available and ./gradlew :app:compileDebugKotlin. Manually restore a tab whose session was deleted and confirm recovery. + diff --git a/.tickets/oa-tqsm.md b/.tickets/oa-tqsm.md new file mode 100644 index 00000000..5d22945e --- /dev/null +++ b/.tickets/oa-tqsm.md @@ -0,0 +1,33 @@ +--- +id: oa-tqsm +status: closed +deps: [] +links: [] +created: 2026-05-10T09:46:14Z +type: chore +priority: 3 +assignee: Jasmin Le Roux +--- +# Delete assistant message block grouping if server parts already preserve structure + +Problem: +MessageBlockUtils groups consecutive assistant MessageWithParts entries into one AssistantBlock by flattening all parts onto the first assistant message. If OpenCode consistently models a single assistant turn as one MessageWithParts with ordered parts, this grouping is extra state-shaping and can hide message-level metadata or errors from later assistant messages. + +Evidence: +MessageBlockUtils.groupMessagesIntoBlocks() collects consecutive Message.Assistant objects and MessageBlockView flattens block.messages.flatMap { it.parts } into a MessageWithParts using block.messages.first().message. The server/domain model already has MessageWithParts for text/tool/reasoning parts inside a message. + +UX Constraint: +Chat should render the server's message/part structure faithfully. Do not lose assistant message errors, metadata, branching/revert identity, or ordering. Preserve current compact visual grouping only if there is a real server behavior that requires it. + +Expected Behavior: +Either delete assistant-message grouping and render each MessageWithParts directly, or document/test the concrete server scenario requiring grouping. + +Acceptance Criteria: +- Verify whether consecutive assistant messages occur in real API/SSE history and why. +- If not needed, remove MessageBlock.AssistantBlock flattening and simplify ChatScreen rendering. +- If needed, preserve all message-level metadata/errors when grouping. +- Add tests for consecutive assistant messages with distinct errors/metadata to prevent silent loss. + +Verification: +Run chat rendering/unit tests and ./gradlew :app:compileDebugKotlin. + diff --git a/.tickets/oa-tzta.md b/.tickets/oa-tzta.md new file mode 100644 index 00000000..30f8cbac --- /dev/null +++ b/.tickets/oa-tzta.md @@ -0,0 +1,35 @@ +--- +id: oa-tzta +status: open +deps: [] +links: [oa-e6g3, oa-ua2q, oa-12ui] +created: 2026-07-05T18:06:47Z +type: bug +priority: 2 +assignee: Jasmin Le Roux +parent: oa-nwha +--- +# Restore file explorer path search and symbol filters per tab + +Problem: +File explorer navigation and filter state can be lost or leak across tabs/workspaces because path/search/symbol state is lifecycle-blind or not clearly scoped. + +Evidence: +Lifecycle audit identified FilesViewModel.kt and FileExplorerScreen.kt path stack, search query, symbol filters, and related file navigation state as restoration-critical. + +UX Constraint: +File navigation state helps prevent wrong-directory mistakes. It should be preserved enough to resume work without taking extra persistent chrome space. + +Expected Behavior: +Each file tab/workspace restores its current path, search query, and symbol/filter state when returning. State is isolated between workspaces/tabs and clears only through explicit user action or clear UX policy. + +Acceptance Criteria: +- Scope explorer path/search/symbol filter state by workspace/tab. +- Persist or save state across tab switches and configuration changes. +- Ensure a different workspace/tab does not inherit another explorer's path/search/filter state. +- Add tests for same-tab restoration and cross-tab/workspace isolation. +- Show human-readable error if restored path no longer exists, with a safe fallback to workspace root. + +Verification: +Run targeted FilesViewModel/FileExplorer tests and smoke test two workspaces/tabs with different explorer paths. + diff --git a/.tickets/oa-ua2q.md b/.tickets/oa-ua2q.md new file mode 100644 index 00000000..23d456b9 --- /dev/null +++ b/.tickets/oa-ua2q.md @@ -0,0 +1,52 @@ +--- +id: oa-ua2q +status: open +deps: [] +links: [oa-tzta, oa-e6g3, oa-plno, oa-casy, oa-12ui] +created: 2026-07-05T18:06:47Z +type: bug +priority: 1 +assignee: Jasmin Le Roux +parent: oa-nwha +--- +# Make workspace-scoped session search explicit + +Problem: +Session search can still treat null directory as global/all-project search, which can cross workspace boundaries and reintroduce old wrong-directory bugs. + +Evidence: +Workspace audit found SessionRepositoryImpl.searchSessions(query, directory: String? = null) treating null as global plus every project worktree, scoped SessionRepositoryImpl.hydrate() still loading global and all project sessions, and tests encoding broad null-directory search. + +UX Constraint: +Users must trust that session search results belong to the active workspace unless they explicitly choose server-global search. Workspace identity should remain compact but clear enough to prevent mistakes. + +Expected Behavior: +Workspace-scoped search searches only the current workspace directory. Server-global/all-workspace search is a separate explicit action/type/name and is labeled in UI. Omitted directory parameters cannot silently broaden scope. + +Acceptance Criteria: +- Remove nullable default arguments that let callers omit search workspace identity. +- Split workspace-scoped search from explicit server-global/all-project search in API/repository names or types. +- Ensure scoped hydrate/search only returns current workspace sessions unless explicitly global. +- Update tests that currently assert broad null-directory search. +- Add regression test with two workspace directories proving scoped search isolation. +- Add UI copy or result labeling for explicit global search if exposed. + +Verification: +Run targeted SessionRepositoryImpl/session list tests and smoke test search in two workspace tabs. + + +## Notes + +**2026-07-06T08:54:53Z** + +Clarification from 2026-07-05 workspace/tab UX discussion: + +Coordinate this ticket with oa-e6g3. The agreed flat-tab UX is: a fresh top-level Sessions tab is intentionally WorkspaceKey.Global and should show all sessions/top-down view. Workspace-scoped search is still required for contextual/session/project views, but Global is not inherently a bug when it is explicit. + +Implementation intent: +- Replace omitted nullable directory defaults with explicit WorkspaceKey input. +- WorkspaceKey.Global search/hydrate means intentional server-wide/all-sessions behavior, appropriate for fresh top-level Sessions. +- WorkspaceKey.Directory(path) search/hydrate means scoped project/session behavior, appropriate for contextual opens. +- Missing legacy workspaceKey = null is not Global; it should recover/ask rather than silently fan out. + +Do not implement this by making every Sessions tab directory-scoped. The bug is global fan-out by omission/ambiguous null, not explicit top-level Global behavior. diff --git a/.tickets/oa-udp9.md b/.tickets/oa-udp9.md new file mode 100644 index 00000000..4ed8af69 --- /dev/null +++ b/.tickets/oa-udp9.md @@ -0,0 +1,39 @@ +--- +id: oa-udp9 +status: closed +deps: [] +links: [] +created: 2026-05-10T09:50:09Z +type: task +priority: 2 +assignee: Jasmin Le Roux +--- +# Scope OFISH capability caches to workspace connection lifecycle + +Problem: +OFISH capability and upload chunk-size probes are cached per FileRepository instance, but FileRepositoryFactory.create() is called from multiple screen/viewmodel paths. This can discard caches and repeat expensive shell probes for the same workspace/server. + +Evidence: +FileRepositoryFactory.create() constructs new CachedOfishCapabilities and CachedOfishUploadChunkBytes every call. It is called from TabNavHost for FilesViewModel/FileViewerScreen and from FilePickerManager's default repository. OFISH probes require shell sessions and capability/chunk commands. + +UX Constraint: +File operations should not repeatedly pay capability-probe latency when the workspace/server has not changed. Cache ownership must remain workspace/server-scoped and must not leak across different servers or workspace generations. + +Expected Behavior: +OFISH capability/chunk caches live for the workspace connection lifecycle, not for each transient repository/viewmodel instance. + +Acceptance Criteria: +- Introduce a workspace/server/generation-scoped FileRepository or OFISH capability cache provider. +- Reuse capability and chunk-size probe results across chat attachments, Files screen, and FileViewer for the same workspace connection. +- Invalidate caches on disconnect, server generation change, workspace change where necessary, or capability probe failure when appropriate. +- Preserve workspace cutover constraints; no global current workspace singleton. + +Verification: +Run file/OFISH tests and ./gradlew :app:compileDebugKotlin. Manually verify repeated file operations in the same workspace do not rerun probes unnecessarily where logs can confirm. + + +## Notes + +**2026-05-10T14:55:14Z** + +Closing as superseded/stale. Runtime OFISH capability probing still exists intentionally because mutations need shell tool/flag detection, but the original repeated-cache concern has been addressed by WorkspaceRepositoryOwner owning a shared FileRepository per workspace owner. Files screen, file viewer, and chat uploads now route through that owner-scoped repository/cache in normal navigation. The ticket's upload chunk-size cache concern is stale because chunk-size probing was removed and uploads use a fixed chunk provider. Reopen only with evidence that multiple WorkspaceRepositoryOwner/FileRepository instances for the same tab/workspace/server generation are causing repeated capability probes. diff --git a/.tickets/oa-uiiw.md b/.tickets/oa-uiiw.md new file mode 100644 index 00000000..4c823f13 --- /dev/null +++ b/.tickets/oa-uiiw.md @@ -0,0 +1,39 @@ +--- +id: oa-uiiw +status: closed +deps: [] +links: [] +created: 2026-05-10T09:50:20Z +type: bug +priority: 2 +assignee: Jasmin Le Roux +--- +# Throttle terminal redraw invalidations to frame rate + +Problem: +TerminalViewModel invalidates the TerminalView once per WebSocket output chunk. Fast terminal output can schedule far more redraws than the display frame rate and saturate the UI thread. + +Evidence: +TerminalViewModel.observeWebSocketOutput() collects ptyWebSocket.output, appends bytes to TerminalEmulator, then calls terminalViewRef?.get()?.postInvalidate() for every data chunk. PtyExited and clearTerminal also invalidate directly. A separate ticket covers removing TerminalView references from the ViewModel. + +UX Constraint: +Terminal output should remain responsive under high-throughput commands such as build logs or cat large files, without Compose recomposition per chunk or UI freezes. + +Expected Behavior: +Terminal rendering coalesces invalidations to roughly one per frame using postOnAnimation, a frame clock, or equivalent view-layer throttling. Network/emulator ingestion remains ordered and lossless. + +Acceptance Criteria: +- Coalesce multiple terminal output chunks into a single pending view invalidation per frame. +- Prefer implementing throttling in the AndroidView/view layer after removing direct View references from TerminalViewModel. +- Preserve immediate redraw for clear/exited states within the same throttling model. +- Add a stress/manual verification command for high-volume terminal output. + +Verification: +Run ./gradlew :app:compileDebugKotlin and manually run a high-output terminal command, confirming UI remains responsive. + + +## Notes + +**2026-05-10T14:43:09Z** + +No implementation needed: terminal invalidations are already routed through the UI layer via terminalInvalidations, and Android/View invalidation already coalesces drawing to frame boundaries enough for current needs. Closing as not actionable unless profiling shows UI-thread churn under real terminal load. diff --git a/.tickets/oa-v3js.md b/.tickets/oa-v3js.md new file mode 100644 index 00000000..c828135e --- /dev/null +++ b/.tickets/oa-v3js.md @@ -0,0 +1,57 @@ +--- +id: oa-v3js +status: closed +deps: [oa-lmh0] +links: [oa-gtw8, oa-t3tb] +created: 2026-05-05T18:19:53Z +type: feature +priority: 1 +assignee: Jasmin Le Roux +parent: oa-ssm2 +tags: [files, editor, sora, phase-5] +--- +# File editor: SoraEditor (AndroidView) + diff-before-save + conflict dialog + +Adopt SoraEditor (https://github.com/Rosemoe/sora-editor) as the file editor. Maven coords: + io.github.Rosemoe.sora-editor:editor: + io.github.Rosemoe.sora-editor:language-textmate: + +Pin a current version after checking releases. Council directive 1 picked Sora over BasicTextField + roll-our-own because user explicitly prioritized editing UX over APK weight. Sora gives undo/redo, line numbers, code folding, gestures (pinch-zoom, magnifier), search/replace, TextMate highlighting — all production-grade. + +Architecture: +- Host via AndroidView(factory = { CodeEditor(ctx).apply { /* one-time init */ } }, update = { /* react to state */ }, onRelease = { it.release() }). onRelease prevents the View leak this kind of heavy native widget caused historically. +- Avoid recreating the editor on recomposition; treat CodeEditor as the source of truth for the editing buffer; surface only text/isDirty/cursor snapshots to the ViewModel via subscribeEvent(ContentChangeEvent::class), debounced. +- Wrap in a Box for chrome; do NOT add .clickable {} — Sora handles its own gestures. + +Theming: +- Generate a TextMate theme JSON in memory from LocalOpenCodeTheme (OpenCodeTheme.kt:64-73 already enumerates syntaxComment/Keyword/Function/Variable/String/Number/Type/Operator/Punctuation). Map to TextMate scopes. Reload via ThemeRegistry whenever LocalOpenCodeTheme changes. ~80 LOC. +- Editor area uses generated TextMate theme; surrounding chrome (toolbar, file path bar, action sheet) stays on LocalOpenCodeTheme. + +Save flow: 1) compute diff via java-diff-utils (see ticket for diff parser swap); 2) render diff modal using existing InlineDiffViewer; 3) on confirm, FileRepository.write with expectedHash; 4) on '### 409 conflict', show 3-pane conflict dialog (yours / theirs / common base). + +Unsaved-changes back-handler: BackHandler intercepts when editor.isModified, shows confirm. + +NOT v1: LSP autocomplete (defer to future ticket via :editor-lsp module). Replaces oa-t3tb. + +## Acceptance Criteria + +SoraEditor renders inside FileViewerScreen edit mode. TextMate theme matches LocalOpenCodeTheme palette. Undo/redo work. Line numbers visible. Save shows diff modal. Stale-hash save shows conflict. Back with unsaved changes shows confirm. Manual test: open .env, change a line, save, verify content on disk matches (use ofish read after save). APK size delta documented in PR. + + +## Notes + +**2026-05-05T18:25:53Z** + +LICENSE FLAG (user caught): SoraEditor is LGPL-2.1, not Apache-2.0. This is acceptable for a Play-Store-distributed Android app but creates compliance obligations: + +1. Add an 'Open source licenses' screen in the app that lists SoraEditor (and other deps) with full LGPL-2.1 text bundled. +2. Display prominent notice that the LGPL library is used (typically inside the licenses screen). +3. Offer the unstripped library object code on request OR reference the upstream unmodified AAR (practical interpretation for Android: 'this is io.github.Rosemoe.sora-editor:editor:, source at github.com/Rosemoe/sora-editor'). +4. DO NOT fork/modify SoraEditor's code — modifications must be LGPL-released back. Theme integration must happen via the public ThemeRegistry/EditorColorScheme APIs only. +5. R8/ProGuard shrinking is fine; static linking via Gradle is fine; Play Store distribution is fine. + +If LGPL compliance is unwanted, alternatives: +- CodeView (Apache-2.0, simpler, regex-based highlighting only) +- BasicTextField + helpers (Apache-2.0, slower to ship, no syntax highlighting in edit mode v1) + +Council missed this — adding to acceptance criteria of this ticket: 'License compliance ticket filed and verified before this ticket closes.' diff --git a/.tickets/oa-vf6h.md b/.tickets/oa-vf6h.md new file mode 100644 index 00000000..cc51094e --- /dev/null +++ b/.tickets/oa-vf6h.md @@ -0,0 +1,35 @@ +--- +id: oa-vf6h +status: open +deps: [] +links: [oa-wmvc, oa-12ui] +created: 2026-07-05T18:06:47Z +type: bug +priority: 2 +assignee: Jasmin Le Roux +parent: oa-nwha +--- +# Resource todo tracker status and progress labels + +Problem: +Todo/progress labels are generated as hardcoded English UI strings instead of resource-backed presentation text. + +Evidence: +Domain/display audit identified TodoTracker.kt status labels and progress strings as user-facing copy outside a clear UI resource boundary. + +UX Constraint: +Todo/progress text should be clear in compact chat/workspace UI and localizable. Status indicators must use the app-wide status language where possible and avoid fake precision or misleading progress. + +Expected Behavior: +Todo tracker data exposes structured status/progress facts. UI formatting maps those facts to resource-backed labels and concise accessible descriptions. + +Acceptance Criteria: +- Separate todo/progress state from user-facing label strings. +- Move todo status/progress labels to resources or a UI formatter using resources. +- Align status wording with the app-wide status dot semantics where applicable. +- Add tests for status/progress formatting that do not depend on domain hardcoded English. +- Preserve meaningful accessibility/content descriptions for functional indicators. + +Verification: +Run targeted todo tracker/UI formatter tests and compile after implementation. + diff --git a/.tickets/oa-vg20.md b/.tickets/oa-vg20.md new file mode 100644 index 00000000..b9418460 --- /dev/null +++ b/.tickets/oa-vg20.md @@ -0,0 +1,26 @@ +--- +id: oa-vg20 +status: closed +deps: [oa-7wc7] +links: [] +created: 2026-05-05T17:48:39Z +type: task +priority: 1 +assignee: Jasmin Le Roux +parent: oa-ssm2 +tags: [files, architecture, phase-2] +--- +# FileRepository interface + move FilesViewModel + strict path validation + +Introduce data/files/FileRepository.kt (interface) with read/list/write/delete/upload/capabilities. Move FilesViewModel off direct WorkspaceClient calls (FilesViewModel.kt:57-58, 114) onto the new repository. Implement strict path validation in the repository (NOT in viewmodels): reject empty, absolute paths, '..' segments, and post-normalization escapes. The current canonicalFilePath (FilesViewModel.kt:144-146) is too weak for mutation. The repository must be workspace-scoped via WorkspaceClient (no global variants — AGENTS.md forbidden patterns 1, 4, 6, 9). Returns rich result types (sealed: Ok / Conflict / Failed) to support the upcoming hash-guarded write. + +## Acceptance Criteria + +FileRepository + ShellFileRepository skeleton (write/delete/upload throw NotImplementedError for now — Phase 4 fills them). FilesViewModel reads/lists through repository. Path validation rejects malicious paths in unit tests. App compiles, file viewer/explorer behave identically. + + +## Notes + +**2026-05-05T19:01:31Z** + +Implemented and committed in e7c59ba. Added FileRepository/WorkspaceFileRepository + FilePathValidator, moved FilesViewModel entirely off WorkspaceClient including symbol search, preserved root/list/status behavior, added validator/repository tests, updated stale inline-permission tests after cleanup. Verified: ./gradlew :app:compileDebugKotlin :app:testDebugUnitTest --tests DialogQueueManagerTest --tests ChatViewModelTest --tests FilePathValidatorTest --tests WorkspaceFileRepositoryTest (green). diff --git a/.tickets/oa-vnoe.md b/.tickets/oa-vnoe.md new file mode 100644 index 00000000..06bdb1f3 --- /dev/null +++ b/.tickets/oa-vnoe.md @@ -0,0 +1,78 @@ +--- +id: oa-vnoe +status: closed +deps: [] +links: [] +created: 2026-05-10T11:41:15Z +type: task +priority: 2 +assignee: Jasmin Le Roux +--- +# Unify session status indicator semantics + +Problem: +Status indicators are overloaded across the app. Backend SessionStatus, tab SessionConnectionState, and per-screen rendering each define similar-but-different meanings. The most confusing case is AWAITING_INPUT: ChatViewModel currently derives it from unread response state, so the warning/attention dot means "there is an unread response" rather than "the agent is blocked waiting for user input." This makes warning indicators noisy and inconsistent with the Settings status legend. + +Evidence: +- domain/model/Event.kt defines backend SessionStatus as Idle, Busy, Retry. +- domain/model/SessionConnectionState.kt defines ACTIVE, BUSY, AWAITING_INPUT, IDLE, BACKGROUND, ERROR and maps colors in SessionStateColors. ACTIVE and BUSY both map to primary; AWAITING_INPUT maps to warning; IDLE maps to muted; BACKGROUND maps to subtle; ERROR maps to error. +- ui/screens/chat/ChatViewModel.kt derives sessionConnectionState as BUSY from isBusy/running tools/streaming text, AWAITING_INPUT from _hasUnreadResponse, else IDLE. This means AWAITING_INPUT is actually unread notification state, not a pending permission/question/tool approval state. +- ui/tabs/TabBar.kt renders tab dots directly from SessionConnectionState and applies pulse/attention badge behavior for AWAITING_INPUT. +- ui/screens/sessions/SessionListScreen.kt has a separate SessionStatusIndicator that renders Busy as spinner + text, Retry as red refresh + text, and Idle as raw Text("●") with success color. +- ui/screens/settings/SettingsScreen.kt includes a status legend describing a unified language, but the implementation is split across multiple mappings and raw glyphs. + +UX Constraint: +The app core value is agent/chat/code workspace space, especially on phones. Do not add persistent chrome. Use dot-only indicators in cramped surfaces such as the tab bar and chat header; use dot+label only in roomier surfaces such as session list rows and Settings help. Reserve motion for states that genuinely need attention. Functional indicators need content descriptions and should use shared components rather than raw glyphs. + +Expected Behavior: +Use one canonical UI-facing status model for session presence/attention, derived from backend SessionStatus plus local UI signals. Backend SessionStatus remains a wire/runtime concept and should not be rendered directly by screens. Unread response state must be distinct from true awaiting-user-input state. + +Suggested model: +- SessionPresence.Error: connection/transport/session error requiring attention. +- SessionPresence.Retrying: transient retry/reconnect/backend retry state. +- SessionPresence.AwaitingInput: agent is blocked on an explicit user decision such as permission, question, or tool approval. This must not be driven by unread response alone. +- SessionPresence.Busy: agent is actively producing output, streaming text, running tools, or backend status is Busy. +- SessionPresence.Unread: agent response completed and the user has not viewed it. +- SessionPresence.Idle: connected/read/no active work. +- SessionPresence.Background: cold/background session or inactive tab with no recent activity. + +Precedence: +Resolve visual status top-down so the highest priority active condition wins: +Error > Retrying > AwaitingInput > Busy > Unread > Idle > Background + +Implementation Notes: +- Add a central resolver, for example resolveSessionPresence(backendStatus, signals), where signals include pendingPrompt/pendingPermission/pendingQuestion, isStreaming, runningTools, hasUnread, isFocused/isActiveTab, hasError, and isBackground/cold. +- Replace SessionStateColors with or wrap it in a visual mapping that owns color, glyph/icon, motion, label, and contentDescription for each SessionPresence. Suggested name: SessionStatusVisuals. +- Add shared status components such as StatusDot and StatusRow. StatusRow should make the text label optional so cramped surfaces can use dot-only. +- Migrate TabBar to render the shared status component instead of directly mapping SessionConnectionState colors/pulse/attention badge. +- Replace SessionListScreen.SessionStatusIndicator with the shared component and remove raw Text("●") status rendering. +- Update Settings status legend so it exactly matches the canonical model and visual mapping. +- Audit sub-agent rows, chat header, file dirty markers, and any other status-like UI for scattered raw dots, ad-hoc status colors, or duplicated mappings. +- Keep file dirty/unsaved state visually compatible with the shared language, but do not conflate it with session presence; use warning/accent marker near the edited file title. + +Acceptance Criteria: +- A single canonical UI-facing session status/presence model exists and is used by tab bar and session list indicators. +- Unread response state no longer uses the same semantic state as awaiting explicit user input. +- AwaitingInput is only shown when there is an actual pending question, permission, approval, or equivalent user-blocking prompt. +- Busy, retrying, idle, unread, background, and error states have documented visual mappings in one shared location. +- Raw status glyph rendering such as Text("●") is removed from session/tab status UI in favor of shared components. +- Settings -> Help status legend matches the implementation exactly. +- Functional status indicators expose meaningful contentDescription values. +- Key interactive/status surfaces keep or add appropriate testTag coverage where practical. +- Motion is limited to Busy, AwaitingInput, and Retrying; Idle, Unread, Background, and Error are static. +- No additional persistent bars, chips, or large chrome are added to chat or tab surfaces. + +Verification: +- Run grep/search for raw status dots and duplicated status color mappings; confirm remaining usages are decorative or justified. +- Run the project build verification: export JAVA_HOME=/usr/lib/jvm/java-17-openjdk && ./gradlew :app:compileDebugKotlin +- Manually verify or add tests for resolver precedence: Error beats Retrying, Retrying beats AwaitingInput, AwaitingInput beats Busy, Busy beats Unread, Unread beats Idle, Idle beats Background. +- Manually verify tab bar, session list, chat header/sub-agent indicators, and Settings legend on narrow/mobile-width layouts. +- Verify unread completed responses produce an unread/accent indication, not a warning awaiting-input indication. +- Verify actual pending question/permission/approval produces warning awaiting-input indication with attention behavior. + + +## Notes + +**2026-05-10T11:47:30Z** + +Implemented canonical SessionPresence resolver and shared status components. Migrated chat tab presence derivation so pending question/permission drives AwaitingInput and unread responses use a separate Unread state. Migrated TabBar, SessionListScreen, ChatScreen connection dot, and Settings status legend to shared status visuals. Verification: export JAVA_HOME=/usr/lib/jvm/java-17-openjdk && ./gradlew :app:compileDebugKotlin passes. diff --git a/.tickets/oa-vvep.md b/.tickets/oa-vvep.md new file mode 100644 index 00000000..1aa13551 --- /dev/null +++ b/.tickets/oa-vvep.md @@ -0,0 +1,26 @@ +--- +id: oa-vvep +status: closed +deps: [] +links: [] +created: 2026-05-01T17:44:25Z +type: task +priority: 1 +assignee: Jasmin Le Roux +parent: oa-gt0g +tags: [design, workspace, sse] +--- +# Design lock B: SSE hydrate-then-stream race semantics + +SessionRepository hydrates initial snapshot via REST then layers SSE events. Race: events arriving DURING hydration can be lost or duplicated. Decide buffer-during-hydrate semantics: snapshot boundary, event identity for dedupe, max buffer size + overflow behavior, behavior on hydration failure, whether lifecycle events (Connected/Disconnected/Error) are buffered. + +## Acceptance Criteria + +1) Decision document covers: snapshot boundary definition, event identity field used for dedupe, max buffer size, overflow policy, hydration-failure recovery, lifecycle event handling. 2) Concrete reducer state shape sketched (Hydrating | Live | Stale). 3) Test cases enumerated for the test-infra ticket to implement. 4) Confirms/decides whether reducer accepts events while in Hydrating state. + + +## Notes + +**2026-05-01T18:19:06Z** + +Decision locked. See docs/design-locks/B-sse-hydrate-race.md diff --git a/.tickets/oa-wfgc.md b/.tickets/oa-wfgc.md new file mode 100644 index 00000000..897f73f7 --- /dev/null +++ b/.tickets/oa-wfgc.md @@ -0,0 +1,42 @@ +--- +id: oa-wfgc +status: closed +deps: [] +links: [] +created: 2026-07-05T18:04:23Z +type: bug +priority: 1 +assignee: Jasmin Le Roux +parent: oa-nwha +--- +# Fix built-in slash command semantics beyond undo redo + +Problem: +Android hardcodes TUI/client built-in slash commands and mixes them with upstream server commands. This creates incorrect semantics and can shadow custom/server/MCP/skill commands. + +Evidence: +Audit found built-in command logic around ChatViewModel.kt, ChatScreen.kt, WorkspaceClient.kt, and command tests. Beyond the existing undo/redo ticket oa-3yk2, likely problematic commands include /compact, missing /summarize alias, /clear, /new, /share, /unshare, /help, /connect, and /bug. Hardcoded commands are prepended before server commands and de-duplicated by name, which means local hardcoded metadata can win over upstream truth. + +UX Constraint: +Slash commands must feel consistent with opencode/TUI behavior while preserving Android-specific local actions. The command palette must not mislead users by showing a command that silently executes the wrong endpoint or shadows a project/server command. + +Expected Behavior: +Each slash command is classified explicitly as local UI action, session route action, upstream server command, or unsupported/degraded. Typed slash input and command-palette selection must use the same dispatcher. Unsupported commands must show a human-readable message rather than being sent to a wrong endpoint. + +Acceptance Criteria: +- Introduce or extend a command dispatcher that classifies built-ins separately from server-provided commands. +- Define correct Android behavior for /compact, /summarize, /clear, /new, /share, /unshare, /help, /connect, and /bug. +- Preserve server/custom/MCP/skill commands without hardcoded Android metadata shadowing upstream definitions. +- Typed slash execution and command palette execution share the same routing path. +- Add tests for typed and palette paths for every classified built-in command. +- Link with oa-3yk2 and oa-dygk so undo/redo and popup metadata work do not diverge. + +Verification: +Run targeted ChatViewModel/command-palette unit tests for typed and palette command routes. Manually smoke test command palette search/display where feasible. Compile after implementation with JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew :app:compileDebugKotlin. + + +## Notes + +**2026-07-05T18:05:35Z** + +Superseded by oa-3yk2. Broader command findings were folded into oa-3yk2 as an add-note so the explicit dispatcher/classification fix covers all built-ins without a duplicate category ticket. diff --git a/.tickets/oa-wmvc.md b/.tickets/oa-wmvc.md new file mode 100644 index 00000000..87c8b8de --- /dev/null +++ b/.tickets/oa-wmvc.md @@ -0,0 +1,82 @@ +--- +id: oa-wmvc +status: closed +deps: [] +links: [oa-nwha, oa-ivwp, oa-vf6h, oa-prjv, oa-3l1w, oa-0mel, oa-12ui, oa-casy, oa-3yk2, oa-qy0f, oa-wxf2, oa-dygk] +created: 2026-05-10T09:49:51Z +type: task +priority: 3 +assignee: Jasmin Le Roux +--- +# file. Permission domain model stores title: String. InlinePermissionPrompt renders permission.title directly, and notifications also use event.permission.title. + +UX Constraint: +Permission prompts and background notifications must remain clear and human-readable, but display text should be produced at UI/notification boundaries using resources when possible. + +Expected Behavior: +EventMapper preserves raw permission type/patterns/metadata. UI/notification layers map permission type to localized string resources or a typed PermissionKind. + +Acceptance Criteria: +- Replace data-layer English title generation with raw/typed permission data. +- Add PermissionKind enum/sealed mapping if useful, preserving unknown permission fallback. +- InlinePermissionPrompt uses stringResource for known permission action text. +- NotificationEventObserver gets localized/human-readable permission titles from Android context/resources. +- Preserve pattern display and unknown permission behavior. + +Verification: +Run mapper/UI tests where available and ./gradlew :app:compileDebugKotlin. + +Problem: +EventMapper converts raw permission tokens into English display titles in the data layer. This bakes UI language into domain data and bypasses string resources/localization. + +Evidence: +Mappers.kt generatePermissionTitle maps bash/shell/edit/write/webfetch/etc to English strings such as Execute + + +## Notes + +**2026-07-05T17:00:17Z** + +Red-test/audit context from 2026-07-05: + +This ticket is the permission-title complaint in the four-complaint red-test batch tracked by parent oa-casy. + +Additional evidence: +- Permission.kt:20-35 exposes Permission.title and computes English labels such as Execute command, Write to file, Run sub-agent, then appends protocol pattern data. +- InlinePermissionPrompt.kt:50-52 renders permission.title directly. +- NotificationEventObserver.kt:95-101 passes the same preformatted permission title into notification code. +- EventMapperTest.kt:120-154 currently asserts an English mapper/domain title, e.g. Execute command: rm -rf /tmp/test, which preserves the wrong layer boundary. +- ToolStateExtTest.kt:102-121 contains the desired red contract that Permission domain should not expose localized display title, but it should be strengthened after the production fix so it is not only reflection-based. + +Red-test expectation: +- Data/domain tests should assert raw permission fields are preserved: id, type/kind, patterns, sessionID, messageID, callID, always, and metadata. +- UI/notification formatter tests should assert known permission kinds map through resources and preserve pattern display separately. +- Tests should not assert English display copy from EventMapper/domain models. + +Implementation direction: +Keep permission type/kind/patterns as domain data. Move kind-to-human text and title formatting to UI/notification boundary using string resources and Context.getString/stringResource. Preserve unknown permission fallback with resource-backed unknown format plus raw protocol code as data. + +**2026-07-06T11:29:49Z** + +Completion update from 2026-07-06: + +Completed the permission localization boundary fix. + +Production changes: +- Removed Permission.title from the domain model; Permission now preserves raw type/patterns/metadata/always plus typed kind mapping only. +- Added PermissionDisplayFormatter at the UI/Android boundary for known PermissionKind resource mapping, unknown permission fallback, and pattern-preserving title formatting. +- InlinePermissionPrompt now renders permission display text via stringResource-backed formatter logic instead of domain Permission.title. +- NotificationHelper now formats permission notification content with Android Context/resources and uses the existing notification_permission_required title resource. +- NotificationEventObserver passes the raw Permission to NotificationHelper rather than preformatted title text. +- Added permission action/title string resources for known kinds, unknown fallback, and pattern formatting. + +Tests: +- Strengthened ToolStateExtTest to assert Permission preserves raw transport fields and does not expose title/displayTitle String APIs. +- Updated EventMapperTest away from stale English title assertions for permission v1/v2 events and toward raw field/kind/metadata preservation. +- Added PermissionDisplayFormatterTest for known kind resource ids, unknown fallback capitalization, and title composition preserving pattern display. + +Verification: +- ./gradlew :app:testDebugUnitTest --tests dev.blazelight.p4oc.domain.model.ToolStateExtTest --tests dev.blazelight.p4oc.data.remote.mapper.EventMapperTest --tests dev.blazelight.p4oc.ui.permission.PermissionDisplayFormatterTest -> PASS +- ./gradlew :app:compileDebugKotlin -> PASS +- ./gradlew :app:detekt -> PASS +- ./gradlew :app:testDebugUnitTest -> PASS diff --git a/.tickets/oa-wqyr.md b/.tickets/oa-wqyr.md new file mode 100644 index 00000000..3e916733 --- /dev/null +++ b/.tickets/oa-wqyr.md @@ -0,0 +1,26 @@ +--- +id: oa-wqyr +status: closed +deps: [] +links: [] +created: 2026-05-05T17:48:39Z +type: feature +priority: 1 +assignee: Jasmin Le Roux +parent: oa-ssm2 +tags: [files, navigation, phase-1] +--- +# Tier A: tab-bar New Files / New Sessions / New Terminal dropdown + +Replace the single + tab action in MainTabScreen.kt:286-288 with a small dropdown menu offering New Sessions tab / New Files tab / New Terminal tab. New tab inherits workspaceDirectory from the active tab (tabs.firstOrNull { it.id == activeTabId }?.workspaceDirectory). Fixes the user-reported bug 'I don't see a way to access files directly' — currently Files can only be opened from inside a chat overflow. Also fixes Terminal discoverability for free. Do NOT add a Files entry to ServerScreen — at that point we don't have a workspace yet (AGENTS.md forbidden patterns 4, 9). + +## Acceptance Criteria + +Fresh-connect users see + dropdown with three options. New Files tab opens FileExplorerScreen scoped to active tab's workspace directory. Workspace is workspace-scoped, not server-global. Council pre-approved. + + +## Notes + +**2026-05-05T18:41:38Z** + +Implemented. MainTabScreen.kt — wrapped TabBar in a Box, added DropdownMenu with three items (Sessions/Files/Terminal). workspaceDirectory inherited via tabs.firstOrNull { it.id == activeTabId }?.workspaceDirectory and passed into each tabManager.createTab(...). Terminal item replicates existing PTY-creation flow (api.createPtySession, then createTab with Screen.Terminal.createRoute(ptyId)). TabBar.kt unchanged — its onAddClick callback is now used to toggle the menu. Icons match TabBar's getIconForRoute mapping. Test tags added per AGENTS.md. Build green. diff --git a/.tickets/oa-ww0m.md b/.tickets/oa-ww0m.md new file mode 100644 index 00000000..32db6c0e --- /dev/null +++ b/.tickets/oa-ww0m.md @@ -0,0 +1,26 @@ +--- +id: oa-ww0m +status: closed +deps: [] +links: [] +created: 2026-05-01T17:44:25Z +type: task +priority: 1 +assignee: Jasmin Le Roux +parent: oa-gt0g +tags: [design, workspace, sse] +--- +# Design lock F: SSE event → workspace routing + +One /global/event stream per server; per-workspace stores filter inbound events. Decide: filter on what field? Events without a directory field — broadcast to all workspaces, drop, or route by sessionID lookup? Permission events specifically (cross-tab — which workspace owns them)? + +## Acceptance Criteria + +1) Event-to-workspace routing rules table per event type. 2) Behavior for events without directory field defined. 3) Cross-tab permission/question routing defined (links to design-A). 4) Sub-agent / child-session event routing defined (does parent workspace see child events?). 5) Behavior when no workspace matches event defined. + + +## Notes + +**2026-05-01T18:19:06Z** + +Decision locked. See docs/design-locks/F-event-routing.md diff --git a/.tickets/oa-wxf2.md b/.tickets/oa-wxf2.md new file mode 100644 index 00000000..77af1e5b --- /dev/null +++ b/.tickets/oa-wxf2.md @@ -0,0 +1,128 @@ +--- +id: oa-wxf2 +status: closed +deps: [] +links: [oa-nwha, oa-77dh, oa-12ui, oa-casy, oa-3yk2, oa-wmvc, oa-qy0f, oa-dygk] +created: 2026-07-05T17:00:06Z +type: bug +priority: 1 +assignee: Jasmin Le Roux +parent: oa-casy +--- +# Replace chat scroll restoration source test with behavior contract + +Problem: +Chat scroll/search/follow-tail state is restoration-critical but current red coverage includes a source-inspection style test that checks for rememberSaveable tokens instead of user-visible behavior. Production code also keeps several restoration-critical chat states in plain remember/rememberLazyListState. + +Evidence: +- ChatScreen.kt:138-147 uses listState, showSearch, searchQuery, currentMatchIndex as composition state. +- ChatScreen.kt:151-154 uses shouldFollowTail, didInitialTailScroll, and hasNewContentWhileAway as composition state. +- MainTabScreen.kt:438-449 wraps tab pages in rememberSaveableStateHolder, but plain remember values do not survive process death and can reset when pages are disposed/recreated. +- ChatScrollRestorationTest.kt:10-29 currently checks source strings such as rememberSaveable rather than restoring the UI and asserting scroll behavior. +- Existing androidTest ChatScreenScrollRestorationTest has a useful behavior contract for returning to chat without forcing tail, but coverage is narrow. + +UX Constraint: +A user reading older messages or searching inside a long chat must not be yanked to the tail or lose search state when switching tabs, rotating, or restoring the app. New messages should only auto-follow when the user intended to follow the tail. + +Expected Behavior: +For the same session/tab, scroll position, follow-tail intent, new-content indicator state, and search query/current match restore across recomposition/configuration restoration. Different sessions must not share scroll state. + +## Design + +Prefer behavior assertions over implementation-token assertions. Save only user-restoration state, not derived caches. Key saved state by stable session/tab identity; do not use a single global scroll holder. + +## Acceptance Criteria + +- Add failing behavior-level test for same-session scroll restoration that recreates/re-enters the chat and proves it does not force tail. +- Add failing behavior-level test that search mode/query/current match restore for the same session if product wants search restoration; otherwise explicitly test/search state clears with a clear UX rationale. +- Add failing behavior-level test that scroll state is scoped by session/tab and does not leak to a different session. +- Remove or demote source-inspection red test after behavior coverage exists; tests should not pass merely because code contains rememberSaveable. +- Production fix uses saveable/session-keyed state or ViewModel SavedStateHandle as appropriate. +- Verification: run targeted chat scroll restoration unit/android tests and ./gradlew :app:compileDebugKotlin. + + +## Notes + +**2026-07-05T17:45:56Z** + +Scroll red-test follow-up on 2026-07-05: + +The current local unit test ChatScrollRestorationTest was renamed to `temporary non acceptance guard requires session scoped saveable chat scroll restoration state`. It is intentionally a source-inspection guard and explicitly does NOT satisfy this ticket's final acceptance criteria. It only remains as a red interim signal that ChatScreen still lacks session-scoped saveable scroll/follow state. + +Attempted behavior-level path: +- Existing primary behavior harness: app/src/androidTest/java/dev/blazelight/p4oc/ui/screens/chat/ChatScreenScrollRestorationTest.kt. +- Desired behavior cases: same-session re-entry keeps Jump to bottom visible after scrolling away; different session/viewmodel does not inherit away-from-tail state; returning to an away-from-tail session does not force tail. +- Blocker: instrumentation starts, but AndroidComposeTestRule fails before the test body with `IllegalStateException: Exception handler was not found via a ServiceLoader` from `kotlinx.coroutines.test.TestScopeImpl.enter`. This prevents grounded Compose behavior assertions. + +Reported attempted fixes by tester peer: +- Debug test app/runner override to avoid app Koin startup. +- androidTest MockK dependencies. +- androidTest coroutines-android. +- debugImplementation(libs.coroutines.test). +- Koin/CredentialStore startup was resolved; remaining blocker is kotlinx.coroutines.test ServiceLoader/provider packaging/classpath. + +Required before closing oa-wxf2: +- Fix androidTest coroutine ServiceLoader/provider classpath so AndroidComposeTestRule can enter test bodies. +- Add/verify behavior tests in ChatScreenScrollRestorationTest for same-session restoration, session isolation, and no forced tail on return. +- Remove or demote the temporary source guard once behavior coverage is running. + +**2026-07-06T09:26:45Z** + +Red verification update from 2026-07-06: + +Current checkout does not contain app/src/androidTest/java/dev/blazelight/p4oc/ui/screens/chat/ChatScreenScrollRestorationTest.kt; globbing for *ScrollRestoration* found only app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/ChatScrollRestorationTest.kt. + +The existing ChatScrollRestorationTest is intentionally marked as a temporary non-acceptance source-inspection guard. It fails because ChatScreen.kt currently uses remember(uiState.session?.id) for shouldFollowTail, didInitialTailScroll, and hasNewContentWhileAway instead of saveable/session-scoped behavior state. This confirms the regression is visible, but it does not satisfy oa-wxf2 acceptance. + +Implementation/fix work for oa-wxf2 still needs behavior-level coverage, ideally Compose UI/androidTest or an extracted state holder/ViewModel/SavedStateHandle test that proves: +- restoring the same session preserves away-from-tail position/follow-tail state, +- switching sessions isolates scroll/follow-tail state by session id, +- returning to an older position does not force-scroll to the tail when new content arrives. + +Focused JVM red suite command used: +JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew :app:testDebugUnitTest --tests dev.blazelight.p4oc.domain.model.ToolStateExtTest --tests dev.blazelight.p4oc.ui.screens.chat.ChatViewModelTest --tests dev.blazelight.p4oc.ui.screens.chat.ModelAgentManagerTest --tests dev.blazelight.p4oc.ui.screens.chat.ChatScrollRestorationTest + +Observed result: 45 tests, 7 intentional failures. Scroll guard failure message: ChatScreen scroll restoration must keep follow-tail state saveable and keyed by session id so reopening a session does not force the list back to the tail. + +**2026-07-06T11:12:11Z** + +Completion update from 2026-07-06: + +Replaced the temporary source-inspection scroll guard with behavior-level JVM tests and implemented a session-scoped restoration seam. + +Production changes: +- Added ChatScrollRestorationStore / ChatScrollRestorationState / InitialTailDecision as an internal chat state holder for restoration-critical scroll/search/follow-tail behavior. +- ChatScreen now uses rememberSaveable keyed by session id for LazyListState and ChatScrollRestorationState. +- Follow-tail, initial tail-scroll completion, away-from-tail new-content affordance, search open/query/current match, search navigation, and jump-to-bottom transitions now flow through the state holder. +- Session identity scopes restoration so a different session starts with default follow-tail state instead of inheriting another session's away-from-tail/search state. +- Preserved the original loading gate: stale messages while uiState.isLoading=true do not consume the one-time initial tail restoration decision; a later ready render can still scroll to tail. + +Tests: +- Removed the temporary non-acceptance source-inspection guard from ChatScrollRestorationTest. +- Added behavior tests for same-session away-from-tail restoration, session isolation, search navigation restoration/isolation, no forced tail on later content while away, jump-to-bottom resuming follow-tail, initial tail restoration happening once without overriding restored away position, and content-not-ready not consuming the initial-tail decision. + +Verification: +- ./gradlew :app:testDebugUnitTest --tests dev.blazelight.p4oc.ui.screens.chat.ChatScrollRestorationTest -> PASS +- ./gradlew :app:compileDebugKotlin -> PASS +- ./gradlew :app:detekt -> PASS +- ./gradlew :app:testDebugUnitTest -> expected FAIL with only one remaining mapped red test outside oa-wxf2: ToolStateExtTest permission localization boundary (oa-wmvc). The oa-wxf2 scroll restoration failure now passes. + +**2026-07-06T11:20:02Z** + +Cleanup update from 2026-07-06: + +Trimmed the scroll restoration seam after review to remove test-only production weight: +- Deleted ChatScrollRestorationStore; production never used it because ChatScreen is already session-keyed with rememberSaveable. +- Inlined trivial one-line wrappers for search open/close, search-match follow-tail mutation, and initial-tail marking. +- Kept the useful core: ChatScrollRestorationState, its Saver, and non-trivial transition methods for scroll settled, tail content changes, content readiness, and jump-to-bottom. +- Updated ChatScrollRestorationTest to instantiate ChatScrollRestorationState directly while preserving behavior coverage. + +LOC check after trim: +- ChatScrollRestorationState.kt: 88 lines +- ChatScrollRestorationTest.kt: 98 lines + +Verification after trim: +- ./gradlew :app:testDebugUnitTest --tests dev.blazelight.p4oc.ui.screens.chat.ChatScrollRestorationTest -> PASS +- ./gradlew :app:compileDebugKotlin -> PASS +- ./gradlew :app:detekt -> PASS +- ./gradlew :app:testDebugUnitTest -> expected FAIL with only the remaining oa-wmvc ToolStateExtTest red failure. diff --git a/.tickets/oa-x9pe.md b/.tickets/oa-x9pe.md new file mode 100644 index 00000000..ad0d7adc --- /dev/null +++ b/.tickets/oa-x9pe.md @@ -0,0 +1,20 @@ +--- +id: oa-x9pe +status: open +deps: [] +links: [oa-9ev3, oa-n1fs, oa-4olr] +created: 2026-05-09T15:46:58Z +type: feature +priority: 2 +assignee: Jasmin Le Roux +--- +# Add terminal copy and paste support + +Terminal should support mobile-friendly clipboard workflows. Today the terminal view accepts keyboard input but does not expose obvious copy/paste affordances.\n\nExpected UX:\n- Users can paste clipboard text into the terminal without relying on OS keyboard tricks.\n- Users can select/copy terminal output.\n- Copy/paste controls should be touch-friendly and consistent with the terminal extra-keys/action UI.\n\nSuggested phased approach:\n1. Add a Paste action/extra key wired to Android ClipboardManager.\n2. Add long-press/select mode with Copy / Paste / Select All toolbar.\n\nAcceptance criteria:\n- Paste inserts clipboard text into the active terminal session.\n- Copy can copy selected terminal output to the clipboard.\n- Long-press/touch behavior does not break terminal focus or soft keyboard behavior.\n- Empty clipboard and non-text clipboard states are handled gracefully.\n- Functional controls have content descriptions/test tags. + + +## Notes + +**2026-05-09T15:52:24Z** + +Standardization note: deliver the full terminal clipboard workflow, not a partial spike. Include both paste and copy/select support unless implementation proves blocked by terminal-view limitations. UI chrome must be justified by agent-space constraints: prefer existing extra-keys/action surfaces and transient selection toolbar over persistent controls. Acceptance should include: paste text clipboard into active terminal; select/copy terminal output; select-all if terminal view supports it; handle empty/non-text clipboard; preserve terminal focus/IME behavior; no persistent chrome that reduces agent/terminal viewport without a strong reason; content descriptions/test tags for actions. diff --git a/.tickets/oa-xi7k.md b/.tickets/oa-xi7k.md new file mode 100644 index 00000000..fd5637a2 --- /dev/null +++ b/.tickets/oa-xi7k.md @@ -0,0 +1,40 @@ +--- +id: oa-xi7k +status: closed +deps: [] +links: [] +created: 2026-05-10T09:49:16Z +type: bug +priority: 1 +assignee: Jasmin Le Roux +--- +# Hoist workspace repositories outside pager composition + +Problem: +WorkspaceViewModel and SessionRepository lifetimes are tied to HorizontalPager/NavHost composition. With beyondViewportPageCount = 0, off-screen tabs can be unmounted, which can clear the WorkspaceViewModel and close the SessionRepository even though the tab still exists. + +Evidence: +MainTabScreen uses HorizontalPager(... beyondViewportPageCount = 0) and creates each tab's rememberNavController/TabNavHost inside SaveableStateProvider(tab.id). TabNavHost calls TouchWorkspaceViewModel from route composables. WorkspaceViewModel.onCleared() calls sessionRepository.close(). TabManager docs say NavController is created inside HorizontalPager page composition scope. + +UX Constraint: +Tabs must continue owning workspace/session state while they exist, including while off-screen. Agent runs and session updates should continue/recover correctly when users swipe between tabs or open settings/files in another tab. + +Expected Behavior: +Workspace/session repository lifetimes are keyed by tab identity and managed outside transient pager page composition. Pager/NavHost renders UI only; it does not own network/session store lifetime. + +Acceptance Criteria: +- Define a tab-scoped owner for WorkspaceViewModel/SessionRepository or equivalent repository holder that survives off-screen pager disposal until the tab is closed or workspace changes. +- Closing a tab explicitly closes/releases its workspace repository. +- Changing a tab workspace recreates the scoped repository with the new workspace identity. +- Preserve generation/server/workspace routing rules; no global/default workspace shortcut. +- Add lifecycle/logging tests or manual verification proving off-screen tabs do not close repositories simply due to pager unmount. + +Verification: +Run ./gradlew :app:compileDebugKotlin. Manually start a long run in one tab, switch to another tab long enough for pager disposal, then return and confirm updates/state are intact. + + +## Notes + +**2026-05-10T11:08:54Z** + +Implemented tab-scoped WorkspaceRepositoryOwner instances in MainTabScreen so SessionRepositoryProvider leases are held outside HorizontalPager page/NavHost composition. Owners are keyed by tab id plus workspace/generation, released when tabs disappear, workspace/generation changes, or MainTabScreen disposes. Verified with export JAVA_HOME=/usr/lib/jvm/java-17-openjdk && ./gradlew :app:compileDebugKotlin. diff --git a/.tickets/oa-xr6w.md b/.tickets/oa-xr6w.md new file mode 100644 index 00000000..2620b686 --- /dev/null +++ b/.tickets/oa-xr6w.md @@ -0,0 +1,15 @@ +--- +id: oa-xr6w +status: closed +deps: [] +links: [] +created: 2026-03-05T19:51:14Z +type: feature +priority: 3 +assignee: Jasmin Le Roux +tags: [ui, files] +--- +# Symbol search in file explorer + +Add symbol search mode to file explorer using GET /find/symbol. Full plumbing exists: SymbolDto, SymbolMapper, Symbol domain model. Show results as dense list with kind indicators and file:line locations. Tap navigates to file viewer. + diff --git a/.tickets/oa-yjii.md b/.tickets/oa-yjii.md new file mode 100644 index 00000000..7e1e516c --- /dev/null +++ b/.tickets/oa-yjii.md @@ -0,0 +1,15 @@ +--- +id: oa-yjii +status: closed +deps: [oa-pecx] +links: [] +created: 2026-03-05T19:51:09Z +type: feature +priority: 2 +assignee: Jasmin Le Roux +tags: [ui, sessions] +--- +# Share/unshare session + +Add share option to session context menu and chat top bar using POST/DELETE /session/:id/share. Show Android share intent with generated URL. Display shared badge on session cards when session.shareUrl != null. API endpoints and Session.shareUrl domain field already mapped. + diff --git a/.tickets/oa-yzwd.md b/.tickets/oa-yzwd.md new file mode 100644 index 00000000..a5e75f1e --- /dev/null +++ b/.tickets/oa-yzwd.md @@ -0,0 +1,39 @@ +--- +id: oa-yzwd +status: closed +deps: [] +links: [] +created: 2026-05-10T09:46:03Z +type: chore +priority: 3 +assignee: Jasmin Le Roux +--- +# Centralize file type metadata for icons, symbols, and language scopes + +Problem: +File extension classification is duplicated across editor language selection, upload visuals, file explorer icons, and file picker icons. This creates drift in how the same file type is represented in different parts of the app. + +Evidence: +SoraLanguageRegistry maps filenames/extensions to TextMate scopes. UploadVisuals maps extensions to TUI glyphs. FileExplorerScreen and FilePickerDialog map extensions again to Material icons/colors. A separate ticket already covers MIME type resolution duplication. + +UX Constraint: +Files should have consistent type identity across editor, file explorer, uploads, and chat attachments while preserving compact TUI presentation. Do not introduce persistent chrome or bulky labels. + +Expected Behavior: +A shared file type registry/classifier maps a filename to semantic file type metadata. UI layers can derive their own icon/glyph/color/scope from that single classification instead of duplicating extension sets. + +Acceptance Criteria: +- Introduce one filename-to-file-type classifier in a ui-neutral or narrowly justified UI shared package. +- Replace duplicated extension grouping in SoraLanguageRegistry, UploadVisuals, FileExplorerScreen, and FilePickerDialog where practical. +- Preserve current supported TextMate scope behavior and visual icon/color behavior. +- Add tests for representative code/config/document/image/archive/shell/build/git/lock/env/web/database files. + +Verification: +Run new classifier tests and ./gradlew :app:compileDebugKotlin. + + +## Notes + +**2026-05-10T15:22:43Z** + +Implemented shared FileTypeClassifier with semantic categories and optional TextMate scopes. Refactored SoraLanguageRegistry, UploadVisuals, FileExplorerScreen, and FilePickerDialog to use it while keeping UI-specific icon/color/glyph mapping in UI layers. Added FileTypeClassifierTest covering representative code/config/document/image/archive/shell/build/git/lock/env/web/database files. Verification: targeted :app:testDebugUnitTest for classifier/Sora/upload visual tests passed; JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew :app:compileDebugKotlin passed. diff --git a/.tickets/oa-zbei.md b/.tickets/oa-zbei.md new file mode 100644 index 00000000..deb4544b --- /dev/null +++ b/.tickets/oa-zbei.md @@ -0,0 +1,39 @@ +--- +id: oa-zbei +status: closed +deps: [] +links: [] +created: 2026-05-10T09:55:02Z +type: bug +priority: 1 +assignee: Jasmin Le Roux +--- +# Rehydrate session repository after SSE reconnect + +Problem: +SessionRepositoryImpl does not rehydrate on SSE reconnect even though OpenCodeEventSource emits OpenCodeEvent.Connected. If events are missed during network loss, the repository can continue from a stale snapshot and never catch up until manual refresh. + +Evidence: +OpenCodeEventSource onOpen sets ConnectionState.Connected and emitEvent(OpenCodeEvent.Connected). SessionRepositoryImpl.acceptEvent only processes events where isSessionEvent(event) is true, and isSessionEvent excludes OpenCodeEvent.Connected. Existing hydrate paths run on init/refresh and some freshness checks, not specifically on reconnect. + +UX Constraint: +After Wi-Fi/cellular switches or app resume reconnects, chat/session state should catch up automatically without missing messages, deltas, session deletes, or status transitions. + +Expected Behavior: +A successful SSE reconnect triggers a forced/background hydrate for affected workspace repositories before or while resuming live event reduction, preserving the SSE-hydrate race lock semantics. + +Acceptance Criteria: +- Handle OpenCodeEvent.Connected or ConnectionState.Connected in repository/store layer to trigger hydrate(force=true) or equivalent catch-up. +- Avoid redundant hydrate storms when multiple tabs share the same workspace repository. +- Preserve HydrationEventBuffer semantics for events arriving during the reconnect hydrate. +- Add tests proving reconnect triggers hydration and missed REST state is incorporated. + +Verification: +Run SessionRepository tests and ./gradlew :app:compileDebugKotlin. Manually simulate SSE reconnect and verify missed session/message changes appear. + + +## Notes + +**2026-05-10T11:11:16Z** + +Handled OpenCodeEvent.Connected in SessionRepositoryImpl by starting a background hydrate when no hydrate is already in flight. The repository enters Hydrating immediately so session events arriving during reconnect are buffered and replayed over the fetched snapshot. Added SessionRepositoryImpl tests for missed REST state after reconnect and SSE event replay during reconnect hydrate. Verified with ./gradlew :app:testDebugUnitTest --tests dev.blazelight.p4oc.data.session.SessionRepositoryImplTest and ./gradlew :app:compileDebugKotlin. diff --git a/app/src/main/java/dev/blazelight/p4oc/core/datastore/SettingsDataStore.kt b/app/src/main/java/dev/blazelight/p4oc/core/datastore/SettingsDataStore.kt index f687ed0f..859a78a1 100644 --- a/app/src/main/java/dev/blazelight/p4oc/core/datastore/SettingsDataStore.kt +++ b/app/src/main/java/dev/blazelight/p4oc/core/datastore/SettingsDataStore.kt @@ -582,19 +582,21 @@ class SettingsDataStore constructor( private fun migrateLegacyPersistedTabState(stored: String): PersistedTabState? { val legacy = json.decodeFromString(stored) if (legacy.version >= PersistedTabState.CURRENT_VERSION) return null + val migratedTabs = legacy.tabs.mapNotNull { tab -> + val workspaceKey = tab.resolvedWorkspaceKey() ?: return@mapNotNull null + PersistedTab( + id = tab.id, + startRoute = tab.startRoute, + sessionId = tab.sessionId, + sessionTitle = tab.sessionTitle, + workspaceKey = workspaceKey, + ) + } return PersistedTabState( version = PersistedTabState.CURRENT_VERSION, serverEndpointKey = legacy.serverEndpointKey, - activeTabId = legacy.activeTabId, - tabs = legacy.tabs.map { tab -> - PersistedTab( - id = tab.id, - startRoute = tab.startRoute, - sessionId = tab.sessionId, - sessionTitle = tab.sessionTitle, - workspaceKey = tab.resolvedWorkspaceKey(), - ) - }, + activeTabId = legacy.activeTabId?.takeIf { activeId -> migratedTabs.any { it.id == activeId } }, + tabs = migratedTabs, ) } } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/MainTabScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/MainTabScreen.kt index b428a7b3..42f2e264 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/MainTabScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/MainTabScreen.kt @@ -174,7 +174,7 @@ fun MainTabScreen( } tabs.forEach { tab -> - val workspaceKey = tab.workspaceKey ?: WorkspaceKey.Global + val workspaceKey = tab.workspaceKey ?: return@forEach val workspace = Workspace( server = ServerRef.fromEndpoint(baseUrl), directory = (workspaceKey as? WorkspaceKey.Directory)?.value, @@ -492,7 +492,13 @@ fun MainTabScreen( snackbarHostState.showSnackbar("Not connected to server") return@launch } - val workspaceKey = tab.workspaceKey ?: WorkspaceKey.Global + val workspaceKey = tab.workspaceKey ?: run { + AppLog.e(TAG, "Cannot create terminal: tab has no workspace identity") + snackbarHostState.showSnackbar( + "Cannot create terminal: workspace is unavailable" + ) + return@launch + } val result = safeApiCall { api.createPtySession( CreatePtyRequest( diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabManager.kt b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabManager.kt index 82e13a9c..70282081 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabManager.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabManager.kt @@ -195,6 +195,7 @@ class TabManager { val restoredTabs = state.tabs.mapNotNull { persisted -> if (persisted.id.isBlank()) return@mapNotNull null + val workspaceKey = persisted.resolvedWorkspaceKey() ?: return@mapNotNull null val route = persisted.sessionId?.let { TabChatRouteCodec.chatRoute(it) } ?: persisted.startRoute.takeIf { it.isNotBlank() } ?: Screen.Sessions.route @@ -203,7 +204,7 @@ class TabManager { id = persisted.id, sessionId = persisted.sessionId, sessionTitle = persisted.sessionTitle, - workspaceKey = persisted.resolvedWorkspaceKey(), + workspaceKey = workspaceKey, ), startRoute = route, ) diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/tabs/TabManagerPersistenceTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/tabs/TabManagerPersistenceTest.kt index 8f6f6c41..bfdaad19 100644 --- a/app/src/test/java/dev/blazelight/p4oc/ui/tabs/TabManagerPersistenceTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/ui/tabs/TabManagerPersistenceTest.kt @@ -61,6 +61,62 @@ class TabManagerPersistenceTest { assertEquals("chat/session%20with%20space", manager.tabs.value.single().startRoute) } + @Test + fun `restoreState drops tab with missing workspace key instead of restoring as global`() { + val manager = TabManager() + val state = PersistedTabState( + serverEndpointKey = server.endpointKey, + activeTabId = "ambiguous-tab", + tabs = listOf( + PersistedTab( + id = "ambiguous-tab", + startRoute = Screen.Sessions.route, + workspaceKey = null, + ), + ), + ) + + val result = manager.restoreState(state, server) + + assertTrue(result is RestoreResult.Empty) + assertFalse(manager.hasTabs()) + } + + @Test + fun `restoreState keeps valid tabs and falls back active tab when ambiguous active tab is dropped`() { + val manager = TabManager() + val state = PersistedTabState( + serverEndpointKey = server.endpointKey, + activeTabId = "ambiguous-tab", + tabs = listOf( + PersistedTab( + id = "ambiguous-tab", + startRoute = Screen.Sessions.route, + workspaceKey = null, + ), + PersistedTab( + id = "directory-tab", + startRoute = Screen.Sessions.route, + workspaceKey = PersistedWorkspaceKey(PersistedWorkspaceKey.Type.DIRECTORY, "/repo/valid"), + ), + PersistedTab( + id = "global-tab", + startRoute = Screen.Sessions.route, + workspaceKey = PersistedWorkspaceKey(PersistedWorkspaceKey.Type.GLOBAL), + ), + ), + ) + + val result = manager.restoreState(state, server) + + assertTrue(result is RestoreResult.Restored) + assertEquals(2, (result as RestoreResult.Restored).count) + assertEquals(listOf("directory-tab", "global-tab"), manager.tabs.value.map { it.id }) + assertEquals("directory-tab", manager.activeTabId.value) + assertEquals("/repo/valid", manager.tabs.value[0].workspaceDirectory) + assertEquals(WorkspaceKey.Global, manager.tabs.value[1].workspaceKey) + } + @Test fun `restoreState rejects mismatched active server without tabs`() { val manager = TabManager() From 2cce18a10e9cb8893e75247759d483c482c80985 Mon Sep 17 00:00:00 2001 From: Jasmin Le Roux Date: Wed, 8 Jul 2026 14:43:46 +0200 Subject: [PATCH 10/22] Close audit lifecycle and workspace tickets --- .tickets/oa-0mel.md | 6 +- .tickets/oa-12ui.md | 8 +- .tickets/oa-3l1w.md | 8 +- .tickets/oa-4olr.md | 8 +- .tickets/oa-9ev3.md | 6 +- .tickets/oa-dygk.md | 6 +- .tickets/oa-e6g3.md | 10 +- .tickets/oa-gtw8.md | 8 +- .tickets/oa-ivwp.md | 8 +- .tickets/oa-n1fs.md | 8 +- .tickets/oa-nwha.md | 8 +- .tickets/oa-plno.md | 8 +- .tickets/oa-prjv.md | 8 +- .tickets/oa-qv8d.md | 8 +- .tickets/oa-r0sq.md | 8 +- .tickets/oa-tzta.md | 8 +- .tickets/oa-ua2q.md | 6 +- .tickets/oa-uahy.md | 8 +- .tickets/oa-vf6h.md | 8 +- .tickets/oa-x112.md | 8 +- .tickets/oa-x9pe.md | 6 +- app/detekt-baseline.xml | 23 ++- .../p4oc/core/network/ConnectionManager.kt | 39 +++- .../notification/NotificationEventObserver.kt | 4 +- .../core/notification/NotificationHelper.kt | 26 ++- .../data/session/SessionRepositoryImpl.kt | 68 +++---- .../dev/blazelight/p4oc/di/KoinModules.kt | 7 +- .../p4oc/ui/components/TermuxExtraKeysBar.kt | 46 ++++- .../p4oc/ui/components/chat/ChatInputBar.kt | 10 +- .../ui/components/chat/SlashCommandsPopup.kt | 61 +++--- .../ui/components/command/CommandMetadata.kt | 58 ++++++ .../p4oc/ui/components/todo/TodoTracker.kt | 94 +++++++--- .../p4oc/ui/screens/chat/ChatScreen.kt | 4 +- .../p4oc/ui/screens/chat/ChatViewModel.kt | 30 ++- .../ui/screens/files/FileExplorerScreen.kt | 43 +++-- .../p4oc/ui/screens/files/FileViewerScreen.kt | 7 +- .../p4oc/ui/screens/files/FilesViewModel.kt | 177 +++++++++++++++--- .../files/editor/SoraLanguageRegistry.kt | 30 +-- .../p4oc/ui/screens/server/ServerScreen.kt | 21 ++- .../ui/screens/sessions/SessionListScreen.kt | 12 +- .../screens/sessions/SessionListViewModel.kt | 68 ++++++- .../p4oc/ui/screens/settings/SkillsScreen.kt | 98 +++++++--- .../ui/screens/terminal/TerminalScreen.kt | 1 + .../ui/screens/terminal/TerminalViewModel.kt | 55 ++++-- .../blazelight/p4oc/ui/tabs/MainTabScreen.kt | 21 ++- .../dev/blazelight/p4oc/ui/tabs/TabBar.kt | 62 ++++-- app/src/main/res/values/strings.xml | 65 +++++++ .../network/ConnectionManagerFallbackTest.kt | 64 +++++++ .../data/session/SessionRepositoryImplTest.kt | 68 +++++++ .../components/chat/SlashCommandsPopupTest.kt | 108 +++++++++++ .../components/command/CommandMetadataTest.kt | 57 ++++++ .../todo/TodoTrackerMetadataTest.kt | 26 +++ .../screens/files/FilesViewModelEditTest.kt | 92 ++++++++- .../files/editor/SoraLanguageRegistryTest.kt | 22 +++ .../server/ServerUrlTextFieldValueTest.kt | 28 +++ .../sessions/SessionListViewModelTest.kt | 150 +++++++++++++++ .../ui/screens/settings/SkillsMetadataTest.kt | 47 +++++ .../ui/tabs/MainTabScreenPtyRequestTest.kt | 30 +++ .../p4oc/ui/tabs/TabBarTitleTest.kt | 95 ++++++++++ 59 files changed, 1775 insertions(+), 302 deletions(-) create mode 100644 app/src/main/java/dev/blazelight/p4oc/ui/components/command/CommandMetadata.kt create mode 100644 app/src/test/java/dev/blazelight/p4oc/core/network/ConnectionManagerFallbackTest.kt create mode 100644 app/src/test/java/dev/blazelight/p4oc/ui/components/chat/SlashCommandsPopupTest.kt create mode 100644 app/src/test/java/dev/blazelight/p4oc/ui/components/command/CommandMetadataTest.kt create mode 100644 app/src/test/java/dev/blazelight/p4oc/ui/components/todo/TodoTrackerMetadataTest.kt create mode 100644 app/src/test/java/dev/blazelight/p4oc/ui/screens/server/ServerUrlTextFieldValueTest.kt create mode 100644 app/src/test/java/dev/blazelight/p4oc/ui/screens/settings/SkillsMetadataTest.kt create mode 100644 app/src/test/java/dev/blazelight/p4oc/ui/tabs/MainTabScreenPtyRequestTest.kt create mode 100644 app/src/test/java/dev/blazelight/p4oc/ui/tabs/TabBarTitleTest.kt diff --git a/.tickets/oa-0mel.md b/.tickets/oa-0mel.md index 9fb15f47..e387cf85 100644 --- a/.tickets/oa-0mel.md +++ b/.tickets/oa-0mel.md @@ -1,6 +1,6 @@ --- id: oa-0mel -status: open +status: closed deps: [] links: [oa-dygk, oa-wmvc, oa-3yk2, oa-12ui] created: 2026-07-05T18:06:47Z @@ -50,3 +50,7 @@ Expected label model after oa-e6g3: Dependency note: The tab title source should switch cleanly after oa-e6g3's TabState.workspaceKey change. Until then, avoid further entrenching workspaceDirectory:String? in new title-formatting APIs. + +**2026-07-07T20:26:14Z** + +Implemented resource-backed tab and slash-command display metadata. Tab title formatting now takes TabTitleLabels populated with stringResource at the MainTabScreen/TabBar UI boundary, with compact tab workspace labels including dedicated tab_workspace_global. Built-in slash commands now carry no ViewModel hardcoded descriptions; builtInCommandDescriptionRes maps local built-ins to R.string.slash_command_* resources, and Compose boundaries resolve those descriptions for ChatInputBar slash filtering/suggestions and CommandPalette while preserving custom/MCP/skill/upstream descriptions. Slash suggestions now display the resolved description line consistently with the palette. Added CommandMetadataTest for built-in resource mapping, unknown fallback, and preservation of custom/MCP/skill/unknown-built-in descriptions; added TabBarTitleTest for route/workspace title cases. Verification: ./gradlew :app:testDebugUnitTest --tests dev.blazelight.p4oc.ui.components.command.CommandMetadataTest --tests dev.blazelight.p4oc.ui.tabs.TabBarTitleTest; ./gradlew :app:compileDebugKotlin. diff --git a/.tickets/oa-12ui.md b/.tickets/oa-12ui.md index 4f0c4f72..a0ae7d63 100644 --- a/.tickets/oa-12ui.md +++ b/.tickets/oa-12ui.md @@ -1,6 +1,6 @@ --- id: oa-12ui -status: open +status: closed deps: [oa-wmvc, oa-3yk2, oa-qy0f, oa-wxf2, oa-ivwp, oa-vf6h, oa-prjv, oa-3l1w, oa-0mel, oa-9ev3, oa-n1fs, oa-gtw8, oa-tzta, oa-plno, oa-ua2q, oa-e6g3, oa-77dh] links: [oa-prjv, oa-tzta, oa-n1fs, oa-qy0f, oa-e6g3, oa-9ev3, oa-wmvc, oa-gtw8, oa-plno, oa-wxf2, oa-vf6h, oa-3l1w, oa-0mel, oa-ivwp, oa-3yk2, oa-ua2q, oa-77dh] created: 2026-07-05T18:04:23Z @@ -34,3 +34,9 @@ Acceptance Criteria: Verification: Run targeted tests for each rewritten area and confirm failures, if any, point to production contract gaps rather than stale test expectations. + +## Notes + +**2026-07-07T21:22:11Z** + +Inventory/resolution summary after child tickets: stale-contract coverage has been rewritten or superseded across the audited areas. Permission/notification/resource display assertions were moved to resource-boundary or pure resId mapper tests in oa-ivwp, oa-3l1w, oa-0mel, oa-prjv, and oa-vf6h rather than asserting English strings in domain state. Slash command typed/palette behavior and metadata were covered by oa-3yk2, oa-0mel, and oa-dygk tests. Model/agent and reasoning default precedence was handled by closed child tickets oa-qy0f/oa-wmvc. Route/tab/workspace persistence tests were updated by oa-e6g3/oa-9ev3 and cleanup fixes to assert explicit WorkspaceKey identity and dropping ambiguous restored tabs instead of legacy guessing. Lifecycle behavior tests were added for chat draft/attachments, file explorer state, file editor drafts, and session list state in oa-77dh/oa-tzta/oa-gtw8/oa-plno. Remaining androidTest-only chat scroll behavior is covered by oa-wxf2's rewritten contract; source-inspection guards are no longer the primary lifecycle proof. Verification across child tickets included their targeted JVM tests plus current ./gradlew :app:compileDebugKotlin and ./gradlew :app:detekt. No additional stale-test blocker remains in the ready set; future stale tests discovered should be opened as specific follow-up bugs rather than keeping this umbrella open. diff --git a/.tickets/oa-3l1w.md b/.tickets/oa-3l1w.md index 9a77248f..e128b18b 100644 --- a/.tickets/oa-3l1w.md +++ b/.tickets/oa-3l1w.md @@ -1,6 +1,6 @@ --- id: oa-3l1w -status: open +status: closed deps: [] links: [oa-wmvc, oa-12ui] created: 2026-07-05T18:06:47Z @@ -32,3 +32,9 @@ Acceptance Criteria: Verification: Run targeted file/language registry tests and compile after implementation. + +## Notes + +**2026-07-07T20:16:05Z** + +Implemented resource-backed file language display labels. SoraLanguageRegistry remains pure and returns technical TextMate scopes only; displayLabelResForScope maps known scopes and null/unknown fallback to R.string.file_language_* IDs. FileViewerScreen resolves the resource ID with stringResource at the UI boundary. Added string resources for known labels and plain text fallback. Added SoraLanguageRegistryTest coverage for known Kotlin/Markdown resource mappings and null/unknown plain-text fallback. Verification: ./gradlew :app:testDebugUnitTest --tests dev.blazelight.p4oc.ui.screens.files.editor.SoraLanguageRegistryTest; ./gradlew :app:compileDebugKotlin. diff --git a/.tickets/oa-4olr.md b/.tickets/oa-4olr.md index 8d0f8a75..ca9c3fda 100644 --- a/.tickets/oa-4olr.md +++ b/.tickets/oa-4olr.md @@ -1,6 +1,6 @@ --- id: oa-4olr -status: open +status: closed deps: [] links: [oa-9ev3, oa-n1fs, oa-x9pe] created: 2026-05-10T09:56:06Z @@ -31,3 +31,9 @@ Acceptance Criteria: Verification: Run ./gradlew :app:compileDebugKotlin and manually test terminal input on software and hardware keyboard scenarios. + +## Notes + +**2026-07-07T21:21:20Z** + +Evaluation result: do not remove the custom TerminalInputView/KeyInterceptingContainer wrapper in this change. Verified through the current implementation and termux-view public API (javap on terminal-view 0.118.0) that TerminalView does expose native onCreateInputConnection/onKeyDown/onKeyUp and text-selection support, but the app wrapper is currently carrying Compose interop behavior that is not trivially redundant: it owns a hidden focusable text-editor view to reliably summon the IME from AndroidView taps, translates common hardware/navigation keys into terminal escape sequences, preserves the existing ctrl/alt extra-key path through TerminalScreen.wrappedKeyInput, and delegates long-press selection to TerminalView by returning false from TerminalViewClient.onLongPress. Removing it would require a device/IME matrix across Gboard, Samsung Keyboard, SwiftKey, hardware keyboards, deletion/composition/arrows/control keys, paste, focus after tab switch, and rotation. Given oa-x9pe paste and oa-n1fs lifecycle changes now depend on the existing focus/session path, the safer decision is to keep the wrapper and document that native TerminalView input may be revisited only with a full manual matrix. Verification for the keep decision: ./gradlew :app:compileDebugKotlin and ./gradlew :app:detekt passed after terminal changes. diff --git a/.tickets/oa-9ev3.md b/.tickets/oa-9ev3.md index c3b66f48..b9e6f61f 100644 --- a/.tickets/oa-9ev3.md +++ b/.tickets/oa-9ev3.md @@ -1,6 +1,6 @@ --- id: oa-9ev3 -status: open +status: closed deps: [] links: [oa-4olr, oa-x9pe, oa-12ui] created: 2026-07-05T18:06:47Z @@ -66,3 +66,7 @@ Required policy with oa-e6g3 WorkspaceKey model: Acceptance addendum: Tests should cover both MainTabScreen PTY construction paths or their extracted request builder: top-level Global does not send '.', and Directory contextual creation sends the directory cwd explicitly. + +**2026-07-07T19:57:28Z** + +Completed PTY request policy. CreatePtyRequest DTO no longer supplies Android-guessed /bin/bash, '.', or Terminal defaults. MainTabScreen now routes both top-level and contextual terminal creation through createPtyRequestForWorkspace: WorkspaceKey.Global omits command/cwd/title for server-delegated defaults; WorkspaceKey.Directory sends cwd equal to the directory and a basename title; missing tab workspace identity is refused with a human-readable snackbar instead of guessed. Added MainTabScreenPtyRequestTest covering Global null command/cwd/title/empty args and Directory explicit cwd/title with cwd != '.'. Verification: ./gradlew :app:testDebugUnitTest --tests dev.blazelight.p4oc.ui.tabs.MainTabScreenPtyRequestTest; ./gradlew :app:compileDebugKotlin. diff --git a/.tickets/oa-dygk.md b/.tickets/oa-dygk.md index 7e6dc9f5..7cb6bef8 100644 --- a/.tickets/oa-dygk.md +++ b/.tickets/oa-dygk.md @@ -1,6 +1,6 @@ --- id: oa-dygk -status: open +status: closed deps: [] links: [oa-0mel, oa-casy, oa-3yk2, oa-wmvc, oa-qy0f, oa-wxf2] created: 2026-05-09T15:47:12Z @@ -22,3 +22,7 @@ Standardization note: deliver complete slash popup UX, not phased partial polish **2026-05-10T15:51:10Z** Design constraint: slash autocomplete rows should be extremely compact and one-line. Prefer showing /name plus a short source badge, with description/agent/model omitted or heavily truncated when space is tight. The popup should prioritize keeping the typed command visible and preserving transcript space over showing full metadata. + +**2026-07-07T20:46:47Z** + +Verified and completed slash command popup placement and metadata behavior. Popup is anchored above the chat input via AboveAnchorPopupPositionProvider, keeps active item visible, shows explicit loading/error/empty states with resource-backed copy, displays compact source badges for built-in/skill/MCP/custom/subtask commands, and now consumes resolved descriptions from oa-0mel for search/display. Exposed pure helpers for filtering/source labels and added SlashCommandsPopupTest coverage for empty/name/description filtering, all source labels, and above-anchor/clamped popup geometry. Verification: ./gradlew :app:testDebugUnitTest --tests dev.blazelight.p4oc.ui.components.chat.SlashCommandsPopupTest; ./gradlew :app:compileDebugKotlin; ./gradlew :app:detekt (fails only on pre-existing SessionListViewModel LongMethod, ConnectionManager ReturnCount, and SessionRepositoryImplTest line-length findings; no SlashCommandsPopup findings remain). diff --git a/.tickets/oa-e6g3.md b/.tickets/oa-e6g3.md index 7ed08b82..44a71556 100644 --- a/.tickets/oa-e6g3.md +++ b/.tickets/oa-e6g3.md @@ -1,6 +1,6 @@ --- id: oa-e6g3 -status: open +status: closed deps: [oa-qv8d] links: [oa-tzta, oa-ua2q, oa-casy, oa-12ui] created: 2026-07-05T18:06:47Z @@ -90,3 +90,11 @@ Blocked on oa-qv8d. Do not resume implementation until plus-button workspace sem **2026-07-06T12:54:34Z** Follow-up cleanup: removed the compile-red speculative WorkspaceKey contract tests from TabManagerPersistenceTest while oa-e6g3 is blocked on oa-qv8d. Left existing persistence tests compiling against current production API. Verified with ./gradlew :app:testDebugUnitTest --tests dev.blazelight.p4oc.ui.tabs.TabManagerPersistenceTest. + +**2026-07-07T19:53:56Z** + +Implemented by WorkspaceKey cutover and follow-up restore hardening. TabManager.createTab now requires a non-null WorkspaceKey with no nullable workspaceDirectory default; TabState stores WorkspaceKey?; persistence writes PersistedWorkspaceKey so explicit Global is distinct from missing/null; restoreState drops current-version tabs with missing workspaceKey and returns Empty if none survive; legacy migration drops unresolved tabs and retains activeTabId only when the active tab survived. Current MainTabScreen uses explicit WorkspaceKey.Global for top-level Sessions and Terminal, Files chooser offers Global and open Directory workspaces, and contextual terminal/sub-session opens preserve the source WorkspaceKey. Verification: ./gradlew :app:testDebugUnitTest --tests dev.blazelight.p4oc.ui.tabs.TabManagerPersistenceTest, ./gradlew :app:compileDebugKotlin, ./gradlew :app:detekt. + +**2026-07-07T19:54:12Z** + +Acceptance deviation documented after closure: the original notes proposed an in-app recovery state for workspace-critical legacy tabs with missing workspace identity. The implemented and user-approved policy is intentionally simpler: ambiguous restored tabs are dropped during migration/restore; if no tabs survive, the app falls back to the normal fresh explicit Global tab path. This still satisfies the invariant that missing workspace identity is never silently converted to Global, but recovery is reset/drop rather than an in-app chooser UI. diff --git a/.tickets/oa-gtw8.md b/.tickets/oa-gtw8.md index d4c8881e..650d304b 100644 --- a/.tickets/oa-gtw8.md +++ b/.tickets/oa-gtw8.md @@ -1,6 +1,6 @@ --- id: oa-gtw8 -status: open +status: closed deps: [] links: [oa-v3js, oa-t3tb, oa-12ui] created: 2026-07-05T18:06:47Z @@ -33,3 +33,9 @@ Acceptance Criteria: Verification: Run targeted file viewer/editor tests and smoke test editing a file, switching tabs, rotating/recreating, and returning. + +## Notes + +**2026-07-07T21:17:58Z** + +Implemented in-app file editor draft preservation using the FilesViewModel SavedStateHandle scope added for oa-tzta. Current source of truth remains FileEditState in FilesViewModel; it is now restored from/persisted to SavedStateHandle per files tab/back-stack entry with path, original content, current draft content, and baseline hash. All FileEditState mutations now go through updateEditState(), keeping transient UI flags and draft content synchronized to SavedStateHandle. Recreating the VM restores dirty drafts as dirty; re-loading the same path preserves a restored dirty draft instead of clobbering it with server content, while saves still use the restored baseline hash for stale-write conflict detection through FileWriteRequest.expectedHash. Added FilesViewModelEditTest coverage for dirty-buffer restoration across VM recreation and same-path load preserving a restored dirty buffer, in addition to existing save/conflict/discard coverage. Verification: ./gradlew :app:testDebugUnitTest --tests dev.blazelight.p4oc.ui.screens.files.FilesViewModelEditTest; ./gradlew :app:compileDebugKotlin; ./gradlew :app:detekt. diff --git a/.tickets/oa-ivwp.md b/.tickets/oa-ivwp.md index 7fa34dbe..d2be5031 100644 --- a/.tickets/oa-ivwp.md +++ b/.tickets/oa-ivwp.md @@ -1,6 +1,6 @@ --- id: oa-ivwp -status: open +status: closed deps: [] links: [oa-wmvc, oa-erzs, oa-12ui] created: 2026-07-05T18:06:47Z @@ -33,3 +33,9 @@ Acceptance Criteria: Verification: Run targeted notification formatter/observer tests and compile with JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew :app:compileDebugKotlin. + +## Notes + +**2026-07-07T20:12:41Z** + +Moved notification channel names/descriptions and event notification titles/fallback bodies to Android string resources. NotificationHelper now uses context.getString for user-input and completion channel metadata, question title, question fallback body, completion title, and completion fallback body. NotificationEventObserver no longer owns the hardcoded 'AI has a question' fallback; it passes nullable question text to the notification boundary. Permission notification title remains aligned with PermissionDisplayFormatter and R.string.notification_permission_required. Verification: grep found no remaining hardcoded notification user-facing strings in core/notification; ./gradlew :app:compileDebugKotlin passed. Unit tests were not added because this project has no Robolectric/resource-backed JVM test setup, so resource correctness is compile-verified. diff --git a/.tickets/oa-n1fs.md b/.tickets/oa-n1fs.md index b616fa72..bc78fc83 100644 --- a/.tickets/oa-n1fs.md +++ b/.tickets/oa-n1fs.md @@ -1,6 +1,6 @@ --- id: oa-n1fs -status: open +status: closed deps: [] links: [oa-4olr, oa-x9pe, oa-12ui] created: 2026-07-05T18:06:47Z @@ -33,3 +33,9 @@ Acceptance Criteria: Verification: Run targeted terminal ViewModel/UI tests and smoke test tab switch/recreate with an active terminal. + +## Notes + +**2026-07-07T21:20:41Z** + +Implemented terminal tab lifecycle identity/scrollback preservation around the existing explicit ptyId route argument. TerminalViewModel now keeps ptyId as the stable terminal identity, restores/persists title and exited state via SavedStateHandle, and replays a capped 64KiB transcript tail into a fresh TerminalEmulator on VM recreation to preserve visible context without risking SavedStateHandle/Bundle overflow. Incoming websocket output and local process-exit messages append to the emulator and the capped transcript snapshot. PTY update/exited events are scoped to this ptyId. Restoring a tab whose ptyId is not returned by listPtySessions now marks it unavailable with a human-readable error instead of silently creating or substituting a new PTY. Verification: ./gradlew :app:compileDebugKotlin; ./gradlew :app:detekt. Manual smoke should still verify visual scroll position/selection details on device, but identity, capped transcript replay, and no-silent-new-PTY behavior are implemented in the VM. diff --git a/.tickets/oa-nwha.md b/.tickets/oa-nwha.md index 73508565..3605c25a 100644 --- a/.tickets/oa-nwha.md +++ b/.tickets/oa-nwha.md @@ -1,6 +1,6 @@ --- id: oa-nwha -status: open +status: closed deps: [] links: [oa-qy0f, oa-wmvc, oa-wxf2, oa-casy, oa-3yk2] created: 2026-07-05T18:03:15Z @@ -31,3 +31,9 @@ Acceptance Criteria: Verification: Use targeted unit/androidTest/Compose tests per child ticket. Run compile/detekt only after implementation work, not as part of ticket creation. + +## Notes + +**2026-07-07T21:22:45Z** + +Epic completion summary: all ready child/follow-up tickets for the audited source-of-truth, resource-boundary, workspace identity, lifecycle restoration, terminal, slash-command, and stale-test contract families are now closed. Work completed includes resource-backed display metadata boundaries; explicit WorkspaceKey tab semantics with ambiguous legacy restore dropping; explicit terminal PTY request defaults; workspace-scoped session search APIs; busy chat follow-up upstream semantics; chat draft/attachment persistence and unavailable attachment recovery; session list search/expansion restoration; file explorer path/search/symbol restoration; file editor draft restoration; terminal clipboard affordance; terminal identity/capped transcript restoration; and the InputConnection keep/remove evaluation. Verification notes are recorded on child tickets; latest project-level verification during this closure: ./gradlew :app:compileDebugKotlin and ./gradlew :app:detekt both passed. tk ready had only oa-12ui and this epic before closing oa-12ui; no specific ready child remains for this epic. diff --git a/.tickets/oa-plno.md b/.tickets/oa-plno.md index a9e0deb8..1b680e5d 100644 --- a/.tickets/oa-plno.md +++ b/.tickets/oa-plno.md @@ -1,6 +1,6 @@ --- id: oa-plno -status: open +status: closed deps: [] links: [oa-ua2q, oa-12ui] created: 2026-07-05T18:06:47Z @@ -33,3 +33,9 @@ Acceptance Criteria: Verification: Run targeted SessionListViewModel/Screen tests and smoke test switching between two workspace session lists. + +## Notes + +**2026-07-07T20:55:16Z** + +Implemented workspace/directory-scoped session list search and tree expansion restoration. SessionListViewModel now receives SavedStateHandle through Koin, persists search queries and expanded session ids by context (global vs directory), restores state on context changes/recreation, and exposes expandedSessionIds/toggleSessionExpanded so SessionListScreen no longer owns lifecycle-blind expansion state. Search clearing resets only the active context; switching contexts prevents query/expanded-session leakage and switching back restores the saved state. Added SessionListViewModelTest coverage for shared-SavedStateHandle recreation, different-directory isolation, blank-query clearing, and restored no-match search semantics. Verification: ./gradlew :app:testDebugUnitTest --tests dev.blazelight.p4oc.ui.screens.sessions.SessionListViewModelTest; ./gradlew :app:compileDebugKotlin; ./gradlew :app:detekt (fails only on pre-existing SessionListViewModel searchSessions LongMethod, ConnectionManager ReturnCount, and SessionRepositoryImplTest line-length findings; no oa-plno-specific findings remain). diff --git a/.tickets/oa-prjv.md b/.tickets/oa-prjv.md index d9a8ce05..9fd1a77c 100644 --- a/.tickets/oa-prjv.md +++ b/.tickets/oa-prjv.md @@ -1,6 +1,6 @@ --- id: oa-prjv -status: open +status: closed deps: [] links: [oa-wmvc, oa-12ui] created: 2026-07-05T18:06:47Z @@ -33,3 +33,9 @@ Acceptance Criteria: Verification: Run targeted Skills/MCP formatter or screen tests and compile after implementation. + +## Notes + +**2026-07-07T20:35:30Z** + +Implemented resource-backed MCP/skills display metadata. SkillsViewModel now exposes structured SkillInfo.status/errorDetail and SkillsErrorKind instead of English display strings. UI resolves known MCP status codes with mcpStatusDescriptionRes and R.string.skills_status_* resources, preserves server-provided errorDetail as upstream technical detail, uses resource/plural-backed tools/resources counts, and keeps raw source/tool/resource identifiers as technical metadata. Added SkillsMetadataTest coverage for known status resource mappings, unknown fallback, connected-only isEnabled behavior, and expected error kinds. Verification: ./gradlew :app:testDebugUnitTest --tests dev.blazelight.p4oc.ui.screens.settings.SkillsMetadataTest; ./gradlew :app:compileDebugKotlin; ./gradlew :app:detekt (fails only on pre-existing SessionListViewModel LongMethod, ConnectionManager ReturnCount, and SessionRepositoryImplTest line-length findings; no oa-prjv/SkillsScreen findings remain). diff --git a/.tickets/oa-qv8d.md b/.tickets/oa-qv8d.md index f97abfb3..87fb7b9c 100644 --- a/.tickets/oa-qv8d.md +++ b/.tickets/oa-qv8d.md @@ -1,6 +1,6 @@ --- id: oa-qv8d -status: open +status: closed deps: [] links: [] created: 2026-07-06T12:52:06Z @@ -31,3 +31,9 @@ No production implementation required; verify by updating oa-e6g3 design notes/a - Update oa-e6g3 notes/acceptance with the chosen policy. - Unblock oa-e6g3 after the decision is recorded. + +## Notes + +**2026-07-07T19:53:26Z** + +Decision recorded: top-level tab-bar plus actions create explicit server/global views for Sessions and Terminal; they do not inherit the active tab workspace. Files remains chooser-first, offering Global plus currently open Directory workspaces. Contextual opens from an existing tab/session/file inherit the source WorkspaceKey. Legacy or missing workspace identity is not guessed as Global: ambiguous restored tabs are dropped during migration/restore, and remaining null workspace paths refuse workspace-critical actions instead of routing globally. Verified against MainTabScreen.kt top-level Sessions/Terminal creation using globalWorkspaceKey, Files chooser options, contextual terminal using the source tab workspaceKey, TabNavHost sub-session opens using workspaceOwner.workspace.key, and TabManager restore tests for null workspaceKey dropping. diff --git a/.tickets/oa-r0sq.md b/.tickets/oa-r0sq.md index b6662a59..ad1a66e7 100644 --- a/.tickets/oa-r0sq.md +++ b/.tickets/oa-r0sq.md @@ -1,6 +1,6 @@ --- id: oa-r0sq -status: open +status: closed deps: [] links: [] created: 2026-05-10T16:27:28Z @@ -38,3 +38,9 @@ Acceptance Criteria: Verification: Manual emulator/device testing should include typing /, filtering to an empty state, scrolling a long list, selecting a command with args, executing /compact, and retrying command loading failure. + +## Notes + +**2026-07-07T20:56:14Z** + +Slash interaction model decision and validation summary: Suggestions are insertion-first, not immediate execution. Selecting a suggestion inserts / plus a trailing space with the cursor at the end so arguments can be entered; submitting a draft that starts with / and has no attachments routes through ChatViewModel.executeCommand(commandName, arguments) rather than normal chat. This applies consistently to built-in, MCP, skill, custom, and subtask commands; command source affects display metadata only, not dispatch path. Built-ins are merged locally because the server command endpoint does not return them; API commands preserve upstream metadata. Popup behavior is now top-level overlay anchored above the input via AboveAnchorPopupPositionProvider, so it does not push agent/model controls or persistent chat chrome and is clamped to the window on small widths/limited vertical space. The list is a bounded LazyColumn with no synthetic item cap; active item scrolls into view during keyboard/dpad navigation. Empty/loading/error states are visible and resource-backed, with human-readable retry content descriptions. Filtering/source-label helpers are shared and covered by SlashCommandsPopupTest; CommandPalette still has its own broader palette filter, but both operate on resolved command descriptions from oa-0mel. Verification: SlashCommandsPopupTest covers empty/name/description filtering, all source labels, above-anchor placement, x clamp, and y clamp; compileDebugKotlin passed. Remaining validation that requires physical keyboards/IME/rotation is better covered by device smoke testing, but the code-level interaction model is now decided and documented. diff --git a/.tickets/oa-tzta.md b/.tickets/oa-tzta.md index 30f8cbac..841be4d1 100644 --- a/.tickets/oa-tzta.md +++ b/.tickets/oa-tzta.md @@ -1,6 +1,6 @@ --- id: oa-tzta -status: open +status: closed deps: [] links: [oa-e6g3, oa-ua2q, oa-12ui] created: 2026-07-05T18:06:47Z @@ -33,3 +33,9 @@ Acceptance Criteria: Verification: Run targeted FilesViewModel/FileExplorer tests and smoke test two workspaces/tabs with different explorer paths. + +## Notes + +**2026-07-07T21:05:42Z** + +Implemented file explorer lifecycle restoration with per-tab SavedStateHandle state. FilesViewModel now accepts SavedStateHandle, restores/persists currentPath, pathStack, local filename search query/active state, symbol mode, and symbol query; FileExplorerScreen now reads these from FilesUiState instead of local remember state. Restored non-blank symbol queries re-run search rather than persisting derived symbol results. Restored missing paths now fall back to the workspace root, clear the path stack, and surface a visible root-path restore warning via resource-backed UI text. Koin now registers both SessionListViewModel and FilesViewModel with SavedStateHandle. Added FilesViewModelEditTest coverage for same-tab SavedStateHandle recreation restoring path stack and filters, symbol query re-fetch, and missing restored path root fallback with user-visible restore error. Verification: ./gradlew :app:testDebugUnitTest --tests dev.blazelight.p4oc.ui.screens.files.FilesViewModelEditTest; ./gradlew :app:compileDebugKotlin; ./gradlew :app:detekt (fails only on the same 7 pre-existing findings: SessionListViewModel searchSessions LongMethod, ConnectionManager connectionCandidates ReturnCount, and SessionRepositoryImplTest line-length findings). diff --git a/.tickets/oa-ua2q.md b/.tickets/oa-ua2q.md index 23d456b9..0a0d1e88 100644 --- a/.tickets/oa-ua2q.md +++ b/.tickets/oa-ua2q.md @@ -1,6 +1,6 @@ --- id: oa-ua2q -status: open +status: closed deps: [] links: [oa-tzta, oa-e6g3, oa-plno, oa-casy, oa-12ui] created: 2026-07-05T18:06:47Z @@ -50,3 +50,7 @@ Implementation intent: - Missing legacy workspaceKey = null is not Global; it should recover/ask rather than silently fan out. Do not implement this by making every Sessions tab directory-scoped. The bug is global fan-out by omission/ambiguous null, not explicit top-level Global behavior. + +**2026-07-07T20:01:47Z** + +Implemented explicit session search split. Removed the public nullable-default searchSessions(query, directory = null) contract and replaced it with searchSessionsInWorkspace(query, directory) for scoped Directory search and searchSessionsGlobally(query) for explicit Global/all-project search. SessionListViewModel now chooses the explicit API based on the current search scope instead of relying on omitted/null broadening. Added SessionRepositoryImpl tests proving scoped search only queries the requested directory with two matching workspaces present, and explicit global search queries null plus known project worktrees and dedupes duplicate ids. Verification: ./gradlew :app:testDebugUnitTest --tests dev.blazelight.p4oc.data.session.SessionRepositoryImplTest.searchSessions*; ./gradlew :app:compileDebugKotlin. diff --git a/.tickets/oa-uahy.md b/.tickets/oa-uahy.md index ddebb1c3..c6d267bc 100644 --- a/.tickets/oa-uahy.md +++ b/.tickets/oa-uahy.md @@ -1,6 +1,6 @@ --- id: oa-uahy -status: open +status: closed deps: [] links: [] created: 2026-07-06T19:23:41Z @@ -31,3 +31,9 @@ Acceptance Criteria: Verification: On debug build, connect screen: fail one connection, select discovered server, then manually replace URL; confirm the field contains exactly one normalized URL and Connect uses that URL. + +## Notes + +**2026-07-07T20:06:10Z** + +Implemented Server URL replacement hardening. The Server URL OutlinedTextField now stores TextFieldValue locally and synchronizes external URL changes through serverUrlTextFieldValue(url), which sets the replacement text exactly and collapses selection at the end. The LaunchedEffect(url) guard only resets selection when the ViewModel-provided URL differs from the current field text, so normal typing is not disrupted while discovered/recent server selection cleanly replaces stale text. Existing connect path already validates ServerUrl.normalizeConnectUrl before network attempts. Added ServerUrlTextFieldValueTest for cursor-at-end replacement behavior. Verification: ./gradlew :app:testDebugUnitTest --tests dev.blazelight.p4oc.ui.screens.server.ServerUrlTextFieldValueTest; ./gradlew :app:compileDebugKotlin. diff --git a/.tickets/oa-vf6h.md b/.tickets/oa-vf6h.md index cc51094e..bbb19924 100644 --- a/.tickets/oa-vf6h.md +++ b/.tickets/oa-vf6h.md @@ -1,6 +1,6 @@ --- id: oa-vf6h -status: open +status: closed deps: [] links: [oa-wmvc, oa-12ui] created: 2026-07-05T18:06:47Z @@ -33,3 +33,9 @@ Acceptance Criteria: Verification: Run targeted todo tracker/UI formatter tests and compile after implementation. + +## Notes + +**2026-07-07T20:42:34Z** + +Implemented resource-backed todo tracker status/progress labels. TodoTracker now uses existing progress_count and percent_complete resources, central todo status constants plus todoStatusLabelRes for status labels, new todo_status_* resources, and semantic ImageVector status indicators with content descriptions on todo rows. Section icons are decorative beside visible status labels to avoid duplicate accessibility announcements; priority remains raw technical metadata. Added TodoTrackerMetadataTest for known status resource mappings and unknown fallback. Verification: ./gradlew :app:testDebugUnitTest --tests dev.blazelight.p4oc.ui.components.todo.TodoTrackerMetadataTest; ./gradlew :app:compileDebugKotlin; ./gradlew :app:detekt (fails only on pre-existing SessionListViewModel LongMethod, ConnectionManager ReturnCount, and SessionRepositoryImplTest line-length findings; no TodoTracker findings remain). diff --git a/.tickets/oa-x112.md b/.tickets/oa-x112.md index 594e5e43..a55e3855 100644 --- a/.tickets/oa-x112.md +++ b/.tickets/oa-x112.md @@ -1,6 +1,6 @@ --- id: oa-x112 -status: open +status: closed deps: [] links: [] created: 2026-07-06T18:07:09Z @@ -31,3 +31,9 @@ Acceptance Criteria: Verification: Test with a reachable host but closed 4096 port and confirm the error references the entered 4096 URL. Test default host without explicit port if fallback behavior is intended. + +## Notes + +**2026-07-07T20:10:24Z** + +Implemented connection fallback fix. ConnectionManager.connectionCandidates now normalizes missing-port input to the OpenCode primary URL first, only adds protocol-default fallback when the original input did not include an explicit port, and explicit http://host:4096 produces only the primary candidate. connect() now preserves the first/primary failure rather than replacing it with a fallback failure if all candidates fail. Added ConnectionManagerFallbackTest covering explicit :4096 primary-only and implicit http://host primary :4096 plus fallback resolving to port 80. Verification: ./gradlew :app:testDebugUnitTest --tests dev.blazelight.p4oc.core.network.ConnectionManagerFallbackTest; ./gradlew :app:compileDebugKotlin. diff --git a/.tickets/oa-x9pe.md b/.tickets/oa-x9pe.md index ad0d7adc..49866b11 100644 --- a/.tickets/oa-x9pe.md +++ b/.tickets/oa-x9pe.md @@ -1,6 +1,6 @@ --- id: oa-x9pe -status: open +status: closed deps: [] links: [oa-9ev3, oa-n1fs, oa-4olr] created: 2026-05-09T15:46:58Z @@ -18,3 +18,7 @@ Terminal should support mobile-friendly clipboard workflows. Today the terminal **2026-05-09T15:52:24Z** Standardization note: deliver the full terminal clipboard workflow, not a partial spike. Include both paste and copy/select support unless implementation proves blocked by terminal-view limitations. UI chrome must be justified by agent-space constraints: prefer existing extra-keys/action surfaces and transient selection toolbar over persistent controls. Acceptance should include: paste text clipboard into active terminal; select/copy terminal output; select-all if terminal view supports it; handle empty/non-text clipboard; preserve terminal focus/IME behavior; no persistent chrome that reduces agent/terminal viewport without a strong reason; content descriptions/test tags for actions. + +**2026-07-07T21:14:00Z** + +Implemented mobile-friendly terminal clipboard affordance with a PST extra key in TermuxExtraKeysBar wired from TerminalScreen to TerminalSession.onPasteTextFromClipboard(), preserving the existing modifier-key input path and terminal focus behavior. Empty/non-text clipboard handling remains delegated to PtyTerminalClient/Termux TerminalSession clipboard handling, which already ignores empty text and catches clipboard access failures. Copy/select support is provided by the existing Termux TerminalView long-press selection mode: TermuxTerminalView keeps TerminalViewClient.onLongPress returning false, which lets TerminalView start its built-in text-selection/floating copy toolbar instead of consuming the event. No persistent chrome was added; the paste action lives in the existing extra-keys bar. Updated detekt baseline for the changed extra-keys Composable signature and new ActionExtraKey. Verification: ./gradlew :app:compileDebugKotlin; ./gradlew :app:detekt. diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml index fe4c7bf4..1edf1d21 100644 --- a/app/detekt-baseline.xml +++ b/app/detekt-baseline.xml @@ -31,7 +31,7 @@ CyclomaticComplexMethod:SessionRepositoryImpl.kt$SessionRepositoryImpl$override fun acceptEvent(event: OpenCodeEvent) CyclomaticComplexMethod:StreamingMarkdown.kt$internal fun parseMarkdownBlocks(text: String): List<MarkdownBlock> CyclomaticComplexMethod:StreamingMarkdown.kt$private fun inlineMarkdown(text: String, colors: MarkdownRenderColors): AnnotatedString - CyclomaticComplexMethod:TabBar.kt$fun getTitleForRoute( route: String?, sessionTitle: String? = null, workspaceKey: WorkspaceKey? = null, ): String + CyclomaticComplexMethod:TabBar.kt$fun getTitleForRoute( route: String?, labels: TabTitleLabels, sessionTitle: String? = null, workspaceKey: WorkspaceKey? = null, ): String CyclomaticComplexMethod:TabNavHost.kt$@Composable fun TabNavHost( navController: NavHostController, tabManager: TabManager, tabId: String, onDisconnect: () -> Unit, onNewFilesTab: () -> Unit = {}, onNewTerminalTab: () -> Unit = {}, onCloseTab: () -> Unit = {}, isActiveTab: Boolean = true, startRoute: String = Screen.Sessions.route, workspaceOwner: WorkspaceRepositoryOwner, onConnectionStateChanged: ((SessionConnectionState?) -> Unit)? = null, modifier: androidx.compose.ui.Modifier = androidx.compose.ui.Modifier ) CyclomaticComplexMethod:TermuxTerminalView.kt$KeyInterceptingContainer$private fun handleKeyDown(event: KeyEvent): Boolean CyclomaticComplexMethod:ToolCallWidget.kt$private fun getToolCompactDescription(tool: Part.Tool): String @@ -161,7 +161,7 @@ FunctionNaming:SessionListScreen.kt$@Composable private fun QuickActionCard( icon: String, title: String, subtitle: String, contentDescription: String, onClick: () -> Unit, modifier: Modifier = Modifier ) FunctionNaming:SessionListScreen.kt$@Composable private fun SessionSearchField( query: String, status: SessionSearchStatus?, onQueryChange: (String) -> Unit, onClose: () -> Unit, ) FunctionNaming:SessionListScreen.kt$@Composable private fun SessionStatusIndicator(status: SessionStatus?, presence: SessionPresence) - FunctionNaming:SessionListScreen.kt$@Composable private fun SessionTreeNode( node: SessionNode, depth: Int, expandedSessions: MutableMap<String, Boolean>, sessionStatuses: Map<String, SessionStatus>, sessionPresences: Map<String, SessionPresence>, showProjectChip: Boolean, onSessionClick: (Session) -> Unit, onDeleteSession: (Session) -> Unit, onRenameSession: (Session) -> Unit, onShareSession: (Session) -> Unit, onViewChanges: (Session) -> Unit, onSummarizeSession: (Session) -> Unit, onProjectClick: (String) -> Unit, onToggleExpand: (String) -> Unit ) + FunctionNaming:SessionListScreen.kt$@Composable private fun SessionTreeNode( node: SessionNode, depth: Int, expandedSessionIds: Set<String>, sessionStatuses: Map<String, SessionStatus>, sessionPresences: Map<String, SessionPresence>, showProjectChip: Boolean, onSessionClick: (Session) -> Unit, onDeleteSession: (Session) -> Unit, onRenameSession: (Session) -> Unit, onShareSession: (Session) -> Unit, onViewChanges: (Session) -> Unit, onSummarizeSession: (Session) -> Unit, onProjectClick: (String) -> Unit, onToggleExpand: (String) -> Unit ) FunctionNaming:SessionListScreen.kt$@OptIn(ExperimentalFoundationApi::class) @Composable private fun SessionCard( session: Session, projectId: String?, projectName: String?, showProjectChip: Boolean, status: SessionStatus?, presence: SessionPresence, isShared: Boolean, onClick: () -> Unit, onDelete: () -> Unit, onRename: () -> Unit, onShare: () -> Unit, onViewChanges: () -> Unit, onSummarize: () -> Unit, onProjectClick: (String) -> Unit, childCount: Int = 0, isExpanded: Boolean = false, onExpandToggle: (() -> Unit)? = null, isSubAgent: Boolean = false ) FunctionNaming:SessionListScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable private fun NewSessionDialog( projects: List<ProjectInfo>, defaultProjectId: String? = null, initialUseCustomDirectory: Boolean = false, onDismiss: () -> Unit, onCreate: (String?, String?) -> Unit ) FunctionNaming:SessionListScreen.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun SessionListScreen( viewModel: SessionListViewModel = koinViewModel(), filterProjectId: String? = null, onSessionClick: (sessionId: String, directory: String?) -> Unit, onNewSession: (sessionId: String, directory: String?) -> Unit, onSettings: () -> Unit, onProjects: () -> Unit = {}, onProjectClick: (directory: String) -> Unit = {}, onViewChanges: (sessionId: String) -> Unit = {}, onCreateSessionInWorkspace: (title: String?, directory: String?) -> Unit = { title, directory -> viewModel.createSession(title, directory) }, autoCreateSession: Boolean = false, autoCreateSessionTitle: String? = null, autoCreateSessionDirectory: String? = null, onAutoCreateSessionConsumed: () -> Unit = {}, onNavigateBack: (() -> Unit)? = null ) @@ -200,7 +200,8 @@ FunctionNaming:TabNavHost.kt$@Composable fun TabNavHost( navController: NavHostController, tabManager: TabManager, tabId: String, onDisconnect: () -> Unit, onNewFilesTab: () -> Unit = {}, onNewTerminalTab: () -> Unit = {}, onCloseTab: () -> Unit = {}, isActiveTab: Boolean = true, startRoute: String = Screen.Sessions.route, workspaceOwner: WorkspaceRepositoryOwner, onConnectionStateChanged: ((SessionConnectionState?) -> Unit)? = null, modifier: androidx.compose.ui.Modifier = androidx.compose.ui.Modifier ) FunctionNaming:TabNavHost.kt$@Composable private fun TouchWorkspaceViewModel( owner: NavBackStackEntry, navController: NavHostController, workspaceRoute: String, workspaceOwner: WorkspaceRepositoryOwner, destinationRoute: String?, ): WorkspaceViewModel FunctionNaming:TerminalScreen.kt$@Composable fun TerminalScreen( viewModel: TerminalViewModel = koinViewModel(), onPtyLoaded: ((ptyId: String, ptyTitle: String) -> Unit)? = null, ) - FunctionNaming:TermuxExtraKeysBar.kt$@Composable fun TermuxExtraKeysBar( onKeyPress: (String) -> Unit, ctrlActive: Boolean, altActive: Boolean, onCtrlToggle: () -> Unit, onAltToggle: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true ) + FunctionNaming:TermuxExtraKeysBar.kt$@Composable fun TermuxExtraKeysBar( onKeyPress: (String) -> Unit, ctrlActive: Boolean, altActive: Boolean, onCtrlToggle: () -> Unit, onAltToggle: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, onPaste: (() -> Unit)? = null, ) + FunctionNaming:TermuxExtraKeysBar.kt$@Composable private fun ActionExtraKey( label: String, enabled: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, ) FunctionNaming:TermuxExtraKeysBar.kt$@Composable private fun ExtraKey( label: String, sequence: String, enabled: Boolean, onKeyPress: (String) -> Unit, modifier: Modifier = Modifier ) FunctionNaming:TermuxExtraKeysBar.kt$@Composable private fun ModifierKey( label: String, active: Boolean, enabled: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier ) FunctionNaming:TermuxExtraKeysBar.kt$@Composable private fun RepeatableExtraKey( label: String, sequence: String, enabled: Boolean, onKeyPress: (String) -> Unit, modifier: Modifier = Modifier ) @@ -209,7 +210,7 @@ FunctionNaming:TodoTracker.kt$@Composable private fun TuiEmptyTodosView() FunctionNaming:TodoTracker.kt$@Composable private fun TuiTodoItem(todo: Todo) FunctionNaming:TodoTracker.kt$@Composable private fun TuiTodoList(todos: List<Todo>) - FunctionNaming:TodoTracker.kt$@Composable private fun TuiTodoSectionHeader(title: String, count: Int, color: Color) + FunctionNaming:TodoTracker.kt$@Composable private fun TuiTodoSectionHeader(status: String, count: Int, color: Color) FunctionNaming:TodoTracker.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun TodoTrackerSheet( todos: List<Todo>, isLoading: Boolean, onDismiss: () -> Unit, onRefresh: () -> Unit ) FunctionNaming:ToolCallWidget.kt$@Composable fun ToolCallCompact( tool: Part.Tool, onClick: (() -> Unit)?, modifier: Modifier = Modifier ) FunctionNaming:ToolCallWidget.kt$@Composable fun ToolCallExpanded( tool: Part.Tool, onClick: (() -> Unit)?, showApprovalActions: Boolean = true, approvalRequestId: String = tool.callID, onToolApprove: (String) -> Unit, onToolDeny: (String) -> Unit, onOpenSubSession: ((String) -> Unit)? = null, modifier: Modifier = Modifier ) @@ -262,7 +263,6 @@ FunctionNaming:VisualSettingsScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ThemeSelector( selected: String, options: List<Pair<String, String>>, onSelect: (String) -> Unit ) FunctionNaming:VisualSettingsScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ToolWidgetStateSelector( selected: String, onSelect: (String) -> Unit ) ImportOrdering:ConnectionManager.kt$import dev.blazelight.p4oc.BuildConfig import dev.blazelight.p4oc.core.datastore.SettingsDataStore import dev.blazelight.p4oc.core.log.AppLog import dev.blazelight.p4oc.data.remote.dto.ProjectDto import dev.blazelight.p4oc.data.remote.mapper.EventMapper import dev.blazelight.p4oc.domain.server.ScopedEvent import dev.blazelight.p4oc.domain.server.ServerGeneration import dev.blazelight.p4oc.domain.server.ServerRef import dev.blazelight.p4oc.domain.server.WorkspaceKey import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import kotlinx.serialization.json.Json import okhttp3.ConnectionPool import okhttp3.Credentials import okhttp3.Dispatcher import okhttp3.Interceptor import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit import retrofit2.converter.kotlinx.serialization.asConverterFactory import java.util.concurrent.TimeUnit - ImportOrdering:SlashCommandsPopup.kt$import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupPositionProvider import androidx.compose.ui.window.PopupProperties import dev.blazelight.p4oc.domain.model.Command import dev.blazelight.p4oc.domain.model.CommandSource import dev.blazelight.p4oc.ui.theme.LocalOpenCodeTheme import dev.blazelight.p4oc.ui.theme.Sizing import dev.blazelight.p4oc.ui.theme.Spacing Indentation:ChatScreen.kt$ InstanceOfCheckForException:DialogQueueManager.kt$DialogQueueManager$e is CancellationException LargeClass:SessionRepositoryImpl.kt$SessionRepositoryImpl : SessionRepository @@ -320,6 +320,7 @@ LongMethod:SessionListScreen.kt$@OptIn(ExperimentalFoundationApi::class) @Composable private fun SessionCard( session: Session, projectId: String?, projectName: String?, showProjectChip: Boolean, status: SessionStatus?, presence: SessionPresence, isShared: Boolean, onClick: () -> Unit, onDelete: () -> Unit, onRename: () -> Unit, onShare: () -> Unit, onViewChanges: () -> Unit, onSummarize: () -> Unit, onProjectClick: (String) -> Unit, childCount: Int = 0, isExpanded: Boolean = false, onExpandToggle: (() -> Unit)? = null, isSubAgent: Boolean = false ) LongMethod:SessionListScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable private fun NewSessionDialog( projects: List<ProjectInfo>, defaultProjectId: String? = null, initialUseCustomDirectory: Boolean = false, onDismiss: () -> Unit, onCreate: (String?, String?) -> Unit ) LongMethod:SessionListScreen.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun SessionListScreen( viewModel: SessionListViewModel = koinViewModel(), filterProjectId: String? = null, onSessionClick: (sessionId: String, directory: String?) -> Unit, onNewSession: (sessionId: String, directory: String?) -> Unit, onSettings: () -> Unit, onProjects: () -> Unit = {}, onProjectClick: (directory: String) -> Unit = {}, onViewChanges: (sessionId: String) -> Unit = {}, onCreateSessionInWorkspace: (title: String?, directory: String?) -> Unit = { title, directory -> viewModel.createSession(title, directory) }, autoCreateSession: Boolean = false, autoCreateSessionTitle: String? = null, autoCreateSessionDirectory: String? = null, onAutoCreateSessionConsumed: () -> Unit = {}, onNavigateBack: (() -> Unit)? = null ) + LongMethod:SessionListViewModel.kt$SessionListViewModel$private fun searchSessions(query: String, directory: String?, debounce: Boolean) LongMethod:SessionRepositoryImpl.kt$SessionRepositoryImpl$override fun acceptEvent(event: OpenCodeEvent) LongMethod:SessionRepositoryImpl.kt$SessionRepositoryImpl$private suspend fun hydrate(seedProjects: List<ProjectDto>): CachedSnapshot LongMethod:SessionUiStateTest.kt$SessionUiStateTest$@Test fun `presence resolver handles status and signal permutations`() @@ -335,6 +336,7 @@ LongMethod:TabNavHost.kt$@Composable fun TabNavHost( navController: NavHostController, tabManager: TabManager, tabId: String, onDisconnect: () -> Unit, onNewFilesTab: () -> Unit = {}, onNewTerminalTab: () -> Unit = {}, onCloseTab: () -> Unit = {}, isActiveTab: Boolean = true, startRoute: String = Screen.Sessions.route, workspaceOwner: WorkspaceRepositoryOwner, onConnectionStateChanged: ((SessionConnectionState?) -> Unit)? = null, modifier: androidx.compose.ui.Modifier = androidx.compose.ui.Modifier ) LongMethod:TerminalScreen.kt$@Composable fun TerminalScreen( viewModel: TerminalViewModel = koinViewModel(), onPtyLoaded: ((ptyId: String, ptyTitle: String) -> Unit)? = null, ) LongMethod:TodoTracker.kt$@Composable private fun TuiTodoItem(todo: Todo) + LongMethod:TodoTracker.kt$@Composable private fun TuiTodoList(todos: List<Todo>) LongMethod:TodoTracker.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun TodoTrackerSheet( todos: List<Todo>, isLoading: Boolean, onDismiss: () -> Unit, onRefresh: () -> Unit ) LongMethod:ToolComponents.kt$@Composable fun DiffPreview( diffContent: String, modifier: Modifier = Modifier ) LongMethod:ToolComponents.kt$@Composable fun EnhancedToolPart( part: Part.Tool, onApprove: (String) -> Unit, onDeny: (String) -> Unit, modifier: Modifier = Modifier ) @@ -367,7 +369,7 @@ LongParameterList:PtyTerminalClient.kt$PtyTerminalClient$( private val context: Context, private val onTextChanged: () -> Unit = {}, private val onTitleChanged: (String?) -> Unit = {}, private val onSessionFinished: () -> Unit = {}, private val onBellCallback: () -> Unit = {}, private val onColorsChangedCallback: () -> Unit = {}, private val onCursorStateChange: (Boolean) -> Unit = {}, private val onPasteRequest: ((String) -> Unit)? = null ) LongParameterList:ServerScreen.kt$( url: String, username: String, password: String, allowInsecure: Boolean, isConnecting: Boolean, onUrlChange: (String) -> Unit, onUsernameChange: (String) -> Unit, onPasswordChange: (String) -> Unit, onAllowInsecureChange: (Boolean) -> Unit, onConnect: () -> Unit ) LongParameterList:SessionListScreen.kt$( icon: String, title: String, subtitle: String, contentDescription: String, onClick: () -> Unit, modifier: Modifier = Modifier ) - LongParameterList:SessionListScreen.kt$( node: SessionNode, depth: Int, expandedSessions: MutableMap<String, Boolean>, sessionStatuses: Map<String, SessionStatus>, sessionPresences: Map<String, SessionPresence>, showProjectChip: Boolean, onSessionClick: (Session) -> Unit, onDeleteSession: (Session) -> Unit, onRenameSession: (Session) -> Unit, onShareSession: (Session) -> Unit, onViewChanges: (Session) -> Unit, onSummarizeSession: (Session) -> Unit, onProjectClick: (String) -> Unit, onToggleExpand: (String) -> Unit ) + LongParameterList:SessionListScreen.kt$( node: SessionNode, depth: Int, expandedSessionIds: Set<String>, sessionStatuses: Map<String, SessionStatus>, sessionPresences: Map<String, SessionPresence>, showProjectChip: Boolean, onSessionClick: (Session) -> Unit, onDeleteSession: (Session) -> Unit, onRenameSession: (Session) -> Unit, onShareSession: (Session) -> Unit, onViewChanges: (Session) -> Unit, onSummarizeSession: (Session) -> Unit, onProjectClick: (String) -> Unit, onToggleExpand: (String) -> Unit ) LongParameterList:SessionListScreen.kt$( session: Session, projectId: String?, projectName: String?, showProjectChip: Boolean, status: SessionStatus?, presence: SessionPresence, isShared: Boolean, onClick: () -> Unit, onDelete: () -> Unit, onRename: () -> Unit, onShare: () -> Unit, onViewChanges: () -> Unit, onSummarize: () -> Unit, onProjectClick: (String) -> Unit, childCount: Int = 0, isExpanded: Boolean = false, onExpandToggle: (() -> Unit)? = null, isSubAgent: Boolean = false ) LongParameterList:SessionListScreen.kt$( viewModel: SessionListViewModel = koinViewModel(), filterProjectId: String? = null, onSessionClick: (sessionId: String, directory: String?) -> Unit, onNewSession: (sessionId: String, directory: String?) -> Unit, onSettings: () -> Unit, onProjects: () -> Unit = {}, onProjectClick: (directory: String) -> Unit = {}, onViewChanges: (sessionId: String) -> Unit = {}, onCreateSessionInWorkspace: (title: String?, directory: String?) -> Unit = { title, directory -> viewModel.createSession(title, directory) }, autoCreateSession: Boolean = false, autoCreateSessionTitle: String? = null, autoCreateSessionDirectory: String? = null, onAutoCreateSessionConsumed: () -> Unit = {}, onNavigateBack: (() -> Unit)? = null ) LongParameterList:SessionWorkspaceClient.kt$SessionWorkspaceClient$( directory: String?, scope: String? = null, roots: Boolean? = null, start: Long? = null, search: String? = null, limit: Int? = null, ) @@ -378,7 +380,7 @@ LongParameterList:TabBar.kt$( tabs: List<TabInstance>, activeTabId: String?, tabTitles: Map<String, String>, tabIcons: Map<String, ImageVector>, tabConnectionStates: Map<String, SessionConnectionState>, onTabClick: (String) -> Unit, onTabClose: (String) -> Unit, onAddClick: () -> Unit, modifier: Modifier = Modifier ) LongParameterList:TabBar.kt$( title: String, icon: ImageVector, connectionState: SessionConnectionState?, isActive: Boolean, onClick: () -> Unit, onClose: () -> Unit, modifier: Modifier = Modifier ) LongParameterList:TabNavHost.kt$( navController: NavHostController, tabManager: TabManager, tabId: String, onDisconnect: () -> Unit, onNewFilesTab: () -> Unit = {}, onNewTerminalTab: () -> Unit = {}, onCloseTab: () -> Unit = {}, isActiveTab: Boolean = true, startRoute: String = Screen.Sessions.route, workspaceOwner: WorkspaceRepositoryOwner, onConnectionStateChanged: ((SessionConnectionState?) -> Unit)? = null, modifier: androidx.compose.ui.Modifier = androidx.compose.ui.Modifier ) - LongParameterList:TermuxExtraKeysBar.kt$( onKeyPress: (String) -> Unit, ctrlActive: Boolean, altActive: Boolean, onCtrlToggle: () -> Unit, onAltToggle: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true ) + LongParameterList:TermuxExtraKeysBar.kt$( onKeyPress: (String) -> Unit, ctrlActive: Boolean, altActive: Boolean, onCtrlToggle: () -> Unit, onAltToggle: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, onPaste: (() -> Unit)? = null, ) LongParameterList:ToolCallWidget.kt$( tool: Part.Tool, onClick: (() -> Unit)?, showApprovalActions: Boolean = true, approvalRequestId: String = tool.callID, onToolApprove: (String) -> Unit, onToolDeny: (String) -> Unit, onOpenSubSession: ((String) -> Unit)? = null, modifier: Modifier = Modifier ) LongParameterList:ToolGroupWidget.kt$( tools: List<Part.Tool>, defaultState: ToolWidgetState, pendingPermissionIdsByCallId: Map<String, String> = emptyMap(), onToolApprove: (String) -> Unit, onToolDeny: (String) -> Unit, onOpenSubSession: ((String) -> Unit)? = null, modifier: Modifier = Modifier ) LongParameterList:TuiComponents.kt$( headlineText: String, modifier: Modifier = Modifier, onClick: (() -> Unit)? = null, leadingIcon: ImageVector? = null, leadingIconTint: Color = LocalOpenCodeTheme.current.textMuted, leadingContent: @Composable (() -> Unit)? = null, supportingText: String? = null, overlineText: String? = null, trailingContent: @Composable (() -> Unit)? = null, enabled: Boolean = true ) @@ -612,6 +614,11 @@ MaxLineLength:SessionRepositoryImpl.kt$SessionRepositoryImpl$return MaxLineLength:SessionRepositoryImpl.kt$SessionRepositoryImpl$state.pendingQuestion?.id == question.id || state.queuedQuestions.any { it.id == question.id } -> state MaxLineLength:SessionRepositoryImpl.kt$SessionRepositoryImpl$val ownerSessionId = synchronized(childToParentSessionIds) { childToParentSessionIds[eventSessionId] } ?: eventSessionId + MaxLineLength:SessionRepositoryImplTest.kt$SessionRepositoryImplTest$FakeWorkspaceClient.sessionDto(id = "global", title = "global match", directory = "/global", updatedAt = 3L) + MaxLineLength:SessionRepositoryImplTest.kt$SessionRepositoryImplTest$FakeWorkspaceClient.sessionDto(id = "repo-a", title = "repo a match", directory = "/repo/a", updatedAt = 5L) + MaxLineLength:SessionRepositoryImplTest.kt$SessionRepositoryImplTest$FakeWorkspaceClient.sessionDto(id = "repo-b", title = "repo b match", directory = "/repo/b", updatedAt = 4L) + MaxLineLength:SessionRepositoryImplTest.kt$SessionRepositoryImplTest$FakeWorkspaceClient.sessionDto(id = "shared", title = "newer match", directory = "/repo/a", updatedAt = 5L) + MaxLineLength:SessionRepositoryImplTest.kt$SessionRepositoryImplTest$FakeWorkspaceClient.sessionDto(id = "shared", title = "older match", directory = "/global", updatedAt = 1L) MaxLineLength:SessionRepositoryImplTest.kt$SessionRepositoryImplTest$projects = listOf(FakeWorkspaceClient.projectDto("p1", "/repo/p1"), FakeWorkspaceClient.projectDto("p2", "/repo/p2")) MaxLineLength:SettingsDataStore.kt$SettingsDataStore$?: MaxLineLength:TabNavHost.kt$TouchWorkspaceViewModel(backStackEntry, navController, workspaceRoute, workspaceOwner, backStackEntry.destination.route) @@ -800,7 +807,7 @@ NoWildcardImports:VisualSettingsScreen.kt$import androidx.compose.material.icons.filled.* NoWildcardImports:VisualSettingsScreen.kt$import androidx.compose.material3.* NoWildcardImports:VisualSettingsScreen.kt$import androidx.compose.runtime.* - ReturnCount:ConnectionManager.kt$ConnectionManager$private fun connectionCandidates(config: ServerConfig): List<ServerConfig> + ReturnCount:ConnectionManager.kt$ConnectionManager$internal fun connectionCandidates(config: ServerConfig): List<ServerConfig> ReturnCount:ExpandedWidgets.kt$private fun extractSubSessionId(tool: Part.Tool, state: ToolState): String? ReturnCount:ExpandedWidgets.kt$private fun getDiffStatsFromTool(tool: Part.Tool): Pair<Int, Int>? ReturnCount:FilePathValidator.kt$FilePathValidator$private fun normalize(path: String, allowRoot: Boolean): Result<String> diff --git a/app/src/main/java/dev/blazelight/p4oc/core/network/ConnectionManager.kt b/app/src/main/java/dev/blazelight/p4oc/core/network/ConnectionManager.kt index 9ad893ec..5939a0c2 100644 --- a/app/src/main/java/dev/blazelight/p4oc/core/network/ConnectionManager.kt +++ b/app/src/main/java/dev/blazelight/p4oc/core/network/ConnectionManager.kt @@ -123,7 +123,7 @@ class ConnectionManager constructor( _connectionState.value = ConnectionState.Connecting val configsToTry = connectionCandidates(config) - var lastError: Throwable? = null + var primaryError: Throwable? = null configsToTry.forEachIndexed { index, candidate -> if (index > 0) { AppLog.d(TAG, "Retrying connection with fallback URL ${candidate.url}") @@ -131,10 +131,10 @@ class ConnectionManager constructor( val result = connectSingle(candidate, password) if (result.isSuccess) return result - lastError = result.exceptionOrNull() + if (primaryError == null) primaryError = result.exceptionOrNull() } - return Result.failure(lastError ?: Exception("Connection failed")) + return Result.failure(primaryError ?: Exception("Connection failed")) } private suspend fun connectSingle(config: ServerConfig, password: String? = null): Result> { @@ -201,20 +201,41 @@ class ConnectionManager constructor( } } - private fun connectionCandidates(config: ServerConfig): List { - val parsed = config.url.toHttpUrlOrNull() ?: return listOf(config) - if (parsed.port != ServerUrl.DEFAULT_PORT) return listOf(config) + internal fun connectionCandidates(config: ServerConfig): List { + val primaryUrl = ServerUrl.normalizeConnectUrl(config.url) ?: config.url + val primaryConfig = if (primaryUrl.trimEnd('/') == config.url.trimEnd('/')) { + config + } else { + config.copy(url = primaryUrl) + } + val parsed = primaryConfig.url.toHttpUrlOrNull() ?: return listOf(primaryConfig) + if (hasExplicitPort(config.url) || parsed.port != ServerUrl.DEFAULT_PORT) return listOf(primaryConfig) val fallbackPort = when (parsed.scheme) { "http" -> 80 "https" -> 443 - else -> return listOf(config) + else -> return listOf(primaryConfig) } val fallbackUrl = parsed.newBuilder().port(fallbackPort).build().toString().trimEnd('/') - if (fallbackUrl == config.url.trimEnd('/')) return listOf(config) + if (fallbackUrl == primaryConfig.url.trimEnd('/')) return listOf(primaryConfig) + + return listOf(primaryConfig, primaryConfig.copy(url = fallbackUrl)) + } + + internal fun hasExplicitPort(url: String): Boolean { + val authority = url + .substringAfter("://", missingDelimiterValue = url) + .substringBefore('/') + .substringBefore('?') + .substringBefore('#') + .substringAfterLast('@') + + if (authority.startsWith("[")) { + return authority.substringAfter("]", missingDelimiterValue = "").startsWith(":") + } - return listOf(config, config.copy(url = fallbackUrl)) + return authority.contains(':') } /** diff --git a/app/src/main/java/dev/blazelight/p4oc/core/notification/NotificationEventObserver.kt b/app/src/main/java/dev/blazelight/p4oc/core/notification/NotificationEventObserver.kt index 04fd69db..3af45bd6 100644 --- a/app/src/main/java/dev/blazelight/p4oc/core/notification/NotificationEventObserver.kt +++ b/app/src/main/java/dev/blazelight/p4oc/core/notification/NotificationEventObserver.kt @@ -102,8 +102,8 @@ class NotificationEventObserver constructor( } is OpenCodeEvent.QuestionAsked -> { if (!cachedSettings.questions) return - val firstQuestion = event.request.questions.firstOrNull()?.question ?: "AI has a question" - AppLog.d(TAG, "Question asked in background: $firstQuestion") + val firstQuestion = event.request.questions.firstOrNull()?.question + AppLog.d(TAG, "Question asked in background") notificationHelper.showQuestionNotification( sessionId = event.request.sessionID, question = firstQuestion diff --git a/app/src/main/java/dev/blazelight/p4oc/core/notification/NotificationHelper.kt b/app/src/main/java/dev/blazelight/p4oc/core/notification/NotificationHelper.kt index aa32696b..9641d139 100644 --- a/app/src/main/java/dev/blazelight/p4oc/core/notification/NotificationHelper.kt +++ b/app/src/main/java/dev/blazelight/p4oc/core/notification/NotificationHelper.kt @@ -21,11 +21,7 @@ class NotificationHelper constructor( ) { companion object { const val CHANNEL_ID = "user_input_required" - const val CHANNEL_NAME = "User Input Required" - const val CHANNEL_DESCRIPTION = "Notifications when AI needs your input" const val COMPLETION_CHANNEL_ID = "assistant_completed" - const val COMPLETION_CHANNEL_NAME = "Assistant completed" - const val COMPLETION_CHANNEL_DESCRIPTION = "Notifications when the assistant finishes a response" private const val PERMISSION_ID_MASK = 0x40000000 private const val QUESTION_ID_MASK = 0x20000000 @@ -49,18 +45,18 @@ class NotificationHelper constructor( if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val inputChannel = NotificationChannel( CHANNEL_ID, - CHANNEL_NAME, + context.getString(R.string.notification_channel_user_input_name), NotificationManager.IMPORTANCE_HIGH ).apply { - description = CHANNEL_DESCRIPTION + description = context.getString(R.string.notification_channel_user_input_desc) enableVibration(true) } val completionChannel = NotificationChannel( COMPLETION_CHANNEL_ID, - COMPLETION_CHANNEL_NAME, + context.getString(R.string.notification_channel_completion_name), NotificationManager.IMPORTANCE_DEFAULT ).apply { - description = COMPLETION_CHANNEL_DESCRIPTION + description = context.getString(R.string.notification_channel_completion_desc) enableVibration(false) setSound(null, null) } @@ -103,7 +99,7 @@ class NotificationHelper constructor( } } - fun showQuestionNotification(sessionId: String, question: String) { + fun showQuestionNotification(sessionId: String, question: String?) { val notificationId = questionNotificationId(sessionId) val intent = Intent(context, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP @@ -118,11 +114,13 @@ class NotificationHelper constructor( PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) + val questionText = question ?: context.getString(R.string.notification_question_fallback) + val notification = NotificationCompat.Builder(context, CHANNEL_ID) .setSmallIcon(R.drawable.ic_launcher_foreground) - .setContentTitle("Question from AI") - .setContentText(question) - .setStyle(NotificationCompat.BigTextStyle().bigText(question)) + .setContentTitle(context.getString(R.string.notification_question_title)) + .setContentText(questionText) + .setStyle(NotificationCompat.BigTextStyle().bigText(questionText)) .setPriority(NotificationCompat.PRIORITY_HIGH) .setAutoCancel(true) .setContentIntent(pendingIntent) @@ -152,8 +150,8 @@ class NotificationHelper constructor( val notification = NotificationCompat.Builder(context, COMPLETION_CHANNEL_ID) .setSmallIcon(R.drawable.ic_launcher_foreground) - .setContentTitle("Assistant finished") - .setContentText(sessionTitle ?: "Response complete") + .setContentTitle(context.getString(R.string.notification_completion_title)) + .setContentText(sessionTitle ?: context.getString(R.string.notification_completion_fallback)) .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setAutoCancel(true) .setOnlyAlertOnce(true) diff --git a/app/src/main/java/dev/blazelight/p4oc/data/session/SessionRepositoryImpl.kt b/app/src/main/java/dev/blazelight/p4oc/data/session/SessionRepositoryImpl.kt index 76dc16ae..5017b771 100644 --- a/app/src/main/java/dev/blazelight/p4oc/data/session/SessionRepositoryImpl.kt +++ b/app/src/main/java/dev/blazelight/p4oc/data/session/SessionRepositoryImpl.kt @@ -138,46 +138,46 @@ class SessionRepositoryImpl( _state.value = RepoState.Live(snapshot) } - suspend fun searchSessions(query: String, directory: String? = null): List { + suspend fun searchSessionsInWorkspace(query: String, directory: String): List { val trimmed = query.trim() if (trimmed.isEmpty()) return emptyList() + return searchSessionsInDirectories(trimmed, listOf(directory)) + } - val projects = if (directory == null) { - runCatching { client.listProjects() }.getOrElse { emptyList() } - } else { - emptyList() - } - val directories = if (directory != null) { - listOf(directory) - } else { - listOf(null) + projects.map { it.worktree } - } - - return coroutineScope { - val results = directories.map { searchDirectory -> - async { - runCatching { - client.listSessions( - directory = searchDirectory, - scope = null, - roots = true, - search = trimmed, - limit = SEARCH_LIMIT, - ).filterNot { dto -> OfishSessionNames.isOfishTitle(dto.title) } - .map { dto -> workspaceSession(SessionMapper.mapToDomain(dto)) } - }.onFailure { error -> - AppLog.e(TAG, "Failed to search sessions for ${searchDirectory ?: "global"}: ${error.message}") - } + suspend fun searchSessionsGlobally(query: String): List { + val trimmed = query.trim() + if (trimmed.isEmpty()) return emptyList() + val projects = runCatching { client.listProjects() }.getOrElse { emptyList() } + return searchSessionsInDirectories(trimmed, listOf(null) + projects.map { it.worktree }) + } + + private suspend fun searchSessionsInDirectories( + query: String, + directories: List, + ): List = coroutineScope { + val results = directories.map { searchDirectory -> + async { + runCatching { + client.listSessions( + directory = searchDirectory, + scope = null, + roots = true, + search = query, + limit = SEARCH_LIMIT, + ).filterNot { dto -> OfishSessionNames.isOfishTitle(dto.title) } + .map { dto -> workspaceSession(SessionMapper.mapToDomain(dto)) } + }.onFailure { error -> + AppLog.e(TAG, "Failed to search sessions for ${searchDirectory ?: "global"}: ${error.message}") } - }.awaitAll() - if (results.all { it.isFailure }) { - throw results.firstNotNullOf { it.exceptionOrNull() } } - results.map { it.getOrElse { emptyList() } } - .flatten() - .distinctBy { it.id.value } - .sortedByDescending { it.session.updatedAt } + }.awaitAll() + if (results.all { it.isFailure }) { + throw results.firstNotNullOf { it.exceptionOrNull() } } + results.map { it.getOrElse { emptyList() } } + .flatten() + .distinctBy { it.id.value } + .sortedByDescending { it.session.updatedAt } } override suspend fun getSession(id: SessionId): WorkspaceSession? { diff --git a/app/src/main/java/dev/blazelight/p4oc/di/KoinModules.kt b/app/src/main/java/dev/blazelight/p4oc/di/KoinModules.kt index 9938683d..e74bbacc 100644 --- a/app/src/main/java/dev/blazelight/p4oc/di/KoinModules.kt +++ b/app/src/main/java/dev/blazelight/p4oc/di/KoinModules.kt @@ -1,5 +1,6 @@ package dev.blazelight.p4oc.di +import androidx.lifecycle.SavedStateHandle import dev.blazelight.p4oc.core.datastore.SettingsDataStore import dev.blazelight.p4oc.core.haptic.HapticFeedback import dev.blazelight.p4oc.core.network.ConnectionManager @@ -126,8 +127,10 @@ val viewModelModule = module { get() ) } - viewModel { params -> SessionListViewModel(params.get()) } - viewModel { params -> FilesViewModel(params.get(), params.get()) } + viewModel { params -> + SessionListViewModel(params.get(), get()) + } + viewModel { params -> FilesViewModel(params.get(), params.get(), get()) } viewModel { params -> TerminalViewModel(params.get(), androidContext(), get(), get()) } } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/components/TermuxExtraKeysBar.kt b/app/src/main/java/dev/blazelight/p4oc/ui/components/TermuxExtraKeysBar.kt index 940e121f..364691f1 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/components/TermuxExtraKeysBar.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/components/TermuxExtraKeysBar.kt @@ -44,7 +44,8 @@ fun TermuxExtraKeysBar( onCtrlToggle: () -> Unit, onAltToggle: () -> Unit, modifier: Modifier = Modifier, - enabled: Boolean = true + enabled: Boolean = true, + onPaste: (() -> Unit)? = null, ) { Column( modifier = modifier @@ -64,6 +65,7 @@ fun TermuxExtraKeysBar( RepeatableExtraKey("↑", "\u001B[A", enabled, onKeyPress, Modifier.weight(1f)) ExtraKey("END", "\u001B[F", enabled, onKeyPress, Modifier.weight(1f)) ExtraKey("PGUP", "\u001B[5~", enabled, onKeyPress, Modifier.weight(1f)) + ActionExtraKey("PST", enabled && onPaste != null, onPaste ?: {}, Modifier.weight(1f)) } // Row 2: TAB CTRL ALT ← ↓ → PGDN @@ -133,6 +135,48 @@ private fun ExtraKey( } } +@Composable +private fun ActionExtraKey( + label: String, + enabled: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + var isPressed by remember { mutableStateOf(false) } + + Box( + modifier = modifier + .background( + color = if (isPressed) SemanticColors.TerminalKeys.keyPressed else Color.Transparent, + shape = RectangleShape + ) + .pointerInput(enabled) { + if (!enabled) return@pointerInput + detectTapGestures( + onPress = { + isPressed = true + tryAwaitRelease() + isPressed = false + }, + onTap = { onClick() }, + ) + }, + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + color = if (enabled) { + SemanticColors.TerminalKeys.keyText + } else { + SemanticColors.TerminalKeys.keyText.copy(alpha = 0.5f) + }, + fontSize = TuiCodeFontSize.md, + fontFamily = FontFamily.Monospace, + textAlign = TextAlign.Center, + ) + } +} + /** * Repeatable extra key button (long-press triggers repeat). * Matches Termux behavior: 400ms initial delay, then 80ms repeat interval. diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ChatInputBar.kt b/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ChatInputBar.kt index 211df171..8770c858 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ChatInputBar.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ChatInputBar.kt @@ -38,6 +38,7 @@ import androidx.compose.ui.text.style.TextOverflow import dev.blazelight.p4oc.R import dev.blazelight.p4oc.domain.model.Command import dev.blazelight.p4oc.ui.components.TuiLoadingIndicator +import dev.blazelight.p4oc.ui.components.command.rememberResolvedCommandMetadata import dev.blazelight.p4oc.ui.theme.LocalOpenCodeTheme import dev.blazelight.p4oc.ui.theme.Sizing import dev.blazelight.p4oc.ui.theme.Spacing @@ -119,15 +120,16 @@ fun ChatInputBar( !enabled -> disconnectedDescription else -> emptyDescription } + val resolvedCommands = rememberResolvedCommandMetadata(commands) // Show slash commands popup when input starts with "/" val showSlashCommands = currentText.startsWith("/") && !currentText.contains(" ") - val filteredCommands = remember(commands, currentText) { + val filteredCommands = remember(resolvedCommands, currentText) { val searchTerm = currentText.removePrefix("/").lowercase() val matches = if (searchTerm.isEmpty()) { - commands + resolvedCommands } else { - commands.filter { cmd -> + resolvedCommands.filter { cmd -> cmd.name.lowercase().contains(searchTerm) || cmd.description?.lowercase()?.contains(searchTerm) == true } @@ -382,7 +384,7 @@ fun ChatInputBar( if (showSlashCommands) { SlashCommandsPopup( state = SlashCommandsPopupState( - commands = commands, + commands = resolvedCommands, filter = currentText, isLoading = isLoadingCommands, error = commandLoadError, diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/SlashCommandsPopup.kt b/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/SlashCommandsPopup.kt index aa212118..d28cceb6 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/SlashCommandsPopup.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/SlashCommandsPopup.kt @@ -15,19 +15,21 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.IntRect -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupPositionProvider import androidx.compose.ui.window.PopupProperties +import dev.blazelight.p4oc.R import dev.blazelight.p4oc.domain.model.Command import dev.blazelight.p4oc.domain.model.CommandSource import dev.blazelight.p4oc.ui.theme.LocalOpenCodeTheme @@ -81,7 +83,7 @@ fun SlashCommandsPopup( ) { when { state.isLoading && filteredCommands.isEmpty() -> { - item { SlashCommandMessage(text = "Loading workspace commands...") } + item { SlashCommandMessage(text = stringResource(R.string.slash_commands_loading)) } } state.error != null -> { item { SlashCommandError(text = state.error, onRetry = callbacks.onRetry) } @@ -89,7 +91,10 @@ fun SlashCommandsPopup( } filteredCommands.isEmpty() -> item { SlashCommandMessage( - text = "No commands match ${state.filter}", + text = stringResource( + R.string.slash_commands_no_match, + state.filter, + ), modifier = Modifier.testTag("slash_commands_empty") ) } @@ -102,7 +107,7 @@ fun SlashCommandsPopup( } } -private class AboveAnchorPopupPositionProvider : PopupPositionProvider { +internal class AboveAnchorPopupPositionProvider : PopupPositionProvider { override fun calculatePosition( anchorBounds: IntRect, windowSize: IntSize, @@ -136,8 +141,12 @@ private fun rememberFilteredCommands( commands: List, filter: String ): List = remember(commands, filter) { + filterSlashCommands(commands, filter) +} + +internal fun filterSlashCommands(commands: List, filter: String): List { val searchTerm = filter.removePrefix("/").lowercase() - if (searchTerm.isEmpty()) { + return if (searchTerm.isEmpty()) { commands } else { commands.filter { command -> @@ -202,7 +211,7 @@ private fun SlashCommandError( ) Icon( imageVector = Icons.Default.Refresh, - contentDescription = "Retry loading commands", + contentDescription = stringResource(R.string.slash_commands_retry_loading), tint = theme.accent, modifier = Modifier .size(Sizing.iconSm) @@ -229,18 +238,28 @@ private fun SlashCommandItem( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.sm) ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "/${command.name}", + color = theme.accent, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + command.description?.let { description -> + Text( + text = description, + style = MaterialTheme.typography.labelSmall, + color = theme.textMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } Text( - text = "/${command.name}", - color = theme.accent, - style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) - ) - Text( - text = command.source.compactLabel(), + text = slashCommandSourceCompactLabel(command.source), style = MaterialTheme.typography.labelSmall, fontFamily = FontFamily.Monospace, color = command.source.badgeColor(), @@ -258,7 +277,7 @@ private fun CommandSource.badgeColor() = when (this) { CommandSource.Subtask -> LocalOpenCodeTheme.current.info } -private fun CommandSource.compactLabel(): String = when (this) { +internal fun slashCommandSourceCompactLabel(source: CommandSource): String = when (source) { CommandSource.BuiltIn -> "[bi]" CommandSource.Skill -> "[skill]" CommandSource.Mcp -> "[mcp]" diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/components/command/CommandMetadata.kt b/app/src/main/java/dev/blazelight/p4oc/ui/components/command/CommandMetadata.kt new file mode 100644 index 00000000..90939ae0 --- /dev/null +++ b/app/src/main/java/dev/blazelight/p4oc/ui/components/command/CommandMetadata.kt @@ -0,0 +1,58 @@ +package dev.blazelight.p4oc.ui.components.command + +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.res.stringResource +import dev.blazelight.p4oc.R +import dev.blazelight.p4oc.domain.model.Command +import dev.blazelight.p4oc.domain.model.CommandSource + +@StringRes +fun builtInCommandDescriptionRes(name: String): Int? = when (name) { + "compact" -> R.string.slash_command_compact_desc + "clear" -> R.string.slash_command_clear_desc + "new" -> R.string.slash_command_new_desc + "undo" -> R.string.slash_command_undo_desc + "redo" -> R.string.slash_command_redo_desc + "share" -> R.string.slash_command_share_desc + "init" -> R.string.slash_command_init_desc + "help" -> R.string.slash_command_help_desc + "connect" -> R.string.slash_command_connect_desc + "bug" -> R.string.slash_command_bug_desc + else -> null +} + +fun resolveBuiltInCommandDescriptions( + commands: List, + builtInDescriptions: Map, +): List = commands.map { command -> + if (command.source == CommandSource.BuiltIn) { + builtInDescriptions[command.name]?.let { command.copy(description = it) } ?: command + } else { + command + } +} + +@Composable +fun rememberResolvedCommandMetadata(commands: List): List { + val builtInDescriptions = builtInCommandNames.associateWith { name -> + stringResource(requireNotNull(builtInCommandDescriptionRes(name))) + } + return remember(commands, builtInDescriptions) { + resolveBuiltInCommandDescriptions(commands, builtInDescriptions) + } +} + +private val builtInCommandNames = listOf( + "compact", + "clear", + "new", + "undo", + "redo", + "share", + "init", + "help", + "connect", + "bug", +) diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/components/todo/TodoTracker.kt b/app/src/main/java/dev/blazelight/p4oc/ui/components/todo/TodoTracker.kt index b623f92d..54bab008 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/components/todo/TodoTracker.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/components/todo/TodoTracker.kt @@ -28,6 +28,20 @@ import dev.blazelight.p4oc.ui.theme.SemanticColors import dev.blazelight.p4oc.ui.theme.Sizing import dev.blazelight.p4oc.ui.theme.Spacing +internal const val TODO_STATUS_PENDING = "pending" +internal const val TODO_STATUS_IN_PROGRESS = "in_progress" +internal const val TODO_STATUS_COMPLETED = "completed" +internal const val TODO_STATUS_CANCELLED = "cancelled" +private const val PERCENT_FACTOR = 100 + +internal fun todoStatusLabelRes(status: String): Int = when (status) { + TODO_STATUS_PENDING -> R.string.todo_status_pending + TODO_STATUS_IN_PROGRESS -> R.string.todo_status_in_progress + TODO_STATUS_COMPLETED -> R.string.todo_status_completed + TODO_STATUS_CANCELLED -> R.string.todo_status_cancelled + else -> R.string.todo_status_unknown +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun TodoTrackerSheet( @@ -37,7 +51,7 @@ fun TodoTrackerSheet( onRefresh: () -> Unit ) { val theme = LocalOpenCodeTheme.current - val completedCount = todos.count { it.status == "completed" } + val completedCount = todos.count { it.status == TODO_STATUS_COMPLETED } val totalCount = todos.size val progress = if (totalCount > 0) completedCount.toFloat() / totalCount else 0f @@ -76,7 +90,7 @@ fun TodoTrackerSheet( ) { if (totalCount > 0) { Text( - text = "$completedCount/$totalCount", + text = stringResource(R.string.progress_count, completedCount, totalCount), style = MaterialTheme.typography.labelMedium, color = theme.accent ) @@ -126,7 +140,10 @@ fun TodoTrackerSheet( ) Spacer(modifier = Modifier.height(Spacing.xs)) Text( - text = "${(progress * 100).toInt()}% complete", + text = stringResource( + R.string.percent_complete, + (progress * PERCENT_FACTOR).toInt(), + ), style = MaterialTheme.typography.labelSmall, color = theme.textMuted ) @@ -187,10 +204,10 @@ private fun TuiEmptyTodosView() { private fun TuiTodoList(todos: List) { val theme = LocalOpenCodeTheme.current val groupedTodos = todos.groupBy { it.status } - val inProgress = groupedTodos["in_progress"] ?: emptyList() - val pending = groupedTodos["pending"] ?: emptyList() - val completed = groupedTodos["completed"] ?: emptyList() - val cancelled = groupedTodos["cancelled"] ?: emptyList() + val inProgress = groupedTodos[TODO_STATUS_IN_PROGRESS] ?: emptyList() + val pending = groupedTodos[TODO_STATUS_PENDING] ?: emptyList() + val completed = groupedTodos[TODO_STATUS_COMPLETED] ?: emptyList() + val cancelled = groupedTodos[TODO_STATUS_CANCELLED] ?: emptyList() LazyColumn( modifier = Modifier @@ -200,7 +217,11 @@ private fun TuiTodoList(todos: List) { ) { if (inProgress.isNotEmpty()) { item { - TuiTodoSectionHeader(title = "▶ in progress", count = inProgress.size, color = theme.accent) + TuiTodoSectionHeader( + status = TODO_STATUS_IN_PROGRESS, + count = inProgress.size, + color = theme.accent, + ) } items(inProgress, key = { it.id }) { todo -> TuiTodoItem(todo = todo) @@ -209,7 +230,11 @@ private fun TuiTodoList(todos: List) { if (pending.isNotEmpty()) { item { - TuiTodoSectionHeader(title = "○ pending", count = pending.size, color = theme.textMuted) + TuiTodoSectionHeader( + status = TODO_STATUS_PENDING, + count = pending.size, + color = theme.textMuted, + ) } items(pending, key = { it.id }) { todo -> TuiTodoItem(todo = todo) @@ -218,7 +243,11 @@ private fun TuiTodoList(todos: List) { if (completed.isNotEmpty()) { item { - TuiTodoSectionHeader(title = "✓ completed", count = completed.size, color = theme.success) + TuiTodoSectionHeader( + status = TODO_STATUS_COMPLETED, + count = completed.size, + color = theme.success, + ) } items(completed, key = { it.id }) { todo -> TuiTodoItem(todo = todo) @@ -227,7 +256,11 @@ private fun TuiTodoList(todos: List) { if (cancelled.isNotEmpty()) { item { - TuiTodoSectionHeader(title = "✗ cancelled", count = cancelled.size, color = theme.error) + TuiTodoSectionHeader( + status = TODO_STATUS_CANCELLED, + count = cancelled.size, + color = theme.error, + ) } items(cancelled, key = { it.id }) { todo -> TuiTodoItem(todo = todo) @@ -237,8 +270,9 @@ private fun TuiTodoList(todos: List) { } @Composable -private fun TuiTodoSectionHeader(title: String, count: Int, color: Color) { +private fun TuiTodoSectionHeader(status: String, count: Int, color: Color) { val theme = LocalOpenCodeTheme.current + val (statusIcon, _) = getStatusInfo(status) Row( modifier = Modifier .fillMaxWidth() @@ -246,8 +280,14 @@ private fun TuiTodoSectionHeader(title: String, count: Int, color: Color) { horizontalArrangement = Arrangement.spacedBy(Spacing.sm), verticalAlignment = Alignment.CenterVertically ) { + Icon( + imageVector = statusIcon, + contentDescription = null, + tint = color, + modifier = Modifier.size(Sizing.iconXs), + ) Text( - text = title, + text = stringResource(todoStatusLabelRes(status)), style = MaterialTheme.typography.labelMedium, color = color ) @@ -264,8 +304,8 @@ private fun TuiTodoItem(todo: Todo) { val theme = LocalOpenCodeTheme.current val (statusIcon, statusColor) = getStatusInfo(todo.status) val priorityColor = getPriorityColor(todo.priority) - val isCompleted = todo.status == "completed" - val isCancelled = todo.status == "cancelled" + val isCompleted = todo.status == TODO_STATUS_COMPLETED + val isCancelled = todo.status == TODO_STATUS_CANCELLED var expanded by remember { mutableStateOf(false) } @@ -289,22 +329,18 @@ private fun TuiTodoItem(todo: Todo) { verticalAlignment = Alignment.Top ) { // Status indicator - Text( - text = when (todo.status) { - "in_progress" -> "▶" - "completed" -> "✓" - "cancelled" -> "✗" - else -> "○" - }, - style = MaterialTheme.typography.bodyMedium, - color = statusColor + Icon( + imageVector = statusIcon, + contentDescription = stringResource(todoStatusLabelRes(todo.status)), + tint = statusColor, + modifier = Modifier.size(Sizing.iconXs), ) Column(modifier = Modifier.weight(1f)) { Text( text = todo.content, style = MaterialTheme.typography.bodySmall, - fontWeight = if (todo.status == "in_progress") FontWeight.Medium else FontWeight.Normal, + fontWeight = if (todo.status == TODO_STATUS_IN_PROGRESS) FontWeight.Medium else FontWeight.Normal, textDecoration = if (isCompleted || isCancelled) TextDecoration.LineThrough else null, color = if (isCompleted || isCancelled) { theme.textMuted @@ -335,10 +371,10 @@ private fun TuiTodoItem(todo: Todo) { @Composable private fun getStatusInfo(status: String): Pair { return when (status) { - "pending" -> Icons.Default.Schedule to SemanticColors.Todo.pending - "in_progress" -> Icons.Default.PlayCircle to SemanticColors.Todo.inProgress - "completed" -> Icons.Default.CheckCircle to SemanticColors.Todo.completed - "cancelled" -> Icons.Default.Cancel to SemanticColors.Todo.cancelled + TODO_STATUS_PENDING -> Icons.Default.Schedule to SemanticColors.Todo.pending + TODO_STATUS_IN_PROGRESS -> Icons.Default.PlayCircle to SemanticColors.Todo.inProgress + TODO_STATUS_COMPLETED -> Icons.Default.CheckCircle to SemanticColors.Todo.completed + TODO_STATUS_CANCELLED -> Icons.Default.Cancel to SemanticColors.Todo.cancelled else -> Icons.Default.Circle to SemanticColors.Todo.pending } } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatScreen.kt index 17f9dcf3..11ac84eb 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatScreen.kt @@ -39,6 +39,7 @@ import dev.blazelight.p4oc.ui.components.chat.FilePickerDialog import dev.blazelight.p4oc.ui.components.chat.JumpToBottomButton import dev.blazelight.p4oc.ui.components.chat.ModelAgentSelectorBar import dev.blazelight.p4oc.ui.components.command.CommandPalette +import dev.blazelight.p4oc.ui.components.command.rememberResolvedCommandMetadata import dev.blazelight.p4oc.ui.components.question.InlineQuestionCard import dev.blazelight.p4oc.ui.components.status.SessionStatusDot import dev.blazelight.p4oc.ui.components.todo.TodoTrackerSheet @@ -484,8 +485,9 @@ fun ChatScreen( } if (showCommandPalette) { + val resolvedCommands = rememberResolvedCommandMetadata(uiState.commands) CommandPalette( - commands = uiState.commands, + commands = resolvedCommands, isLoading = uiState.isLoadingCommands, error = uiState.commandLoadError, onRetry = { viewModel.refreshCommandsIfNeeded(force = true) }, diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModel.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModel.kt index f6f86e2e..baa33bfb 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModel.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModel.kt @@ -150,27 +150,19 @@ class ChatViewModel constructor( /** * Built-in OpenCode commands that aren't returned by the /command API endpoint. - * These are hardcoded based on OpenCode documentation. + * Localized descriptions are resolved at Compose display boundaries. */ private val BUILTIN_COMMANDS = listOf( - Command( - name = "compact", - description = "Compact the conversation to reduce context size", - source = CommandSource.BuiltIn - ), - Command(name = "clear", description = "Clear the conversation history", source = CommandSource.BuiltIn), - Command(name = "new", description = "Start a new conversation", source = CommandSource.BuiltIn), - Command(name = "undo", description = "Undo the last change", source = CommandSource.BuiltIn), - Command(name = "redo", description = "Redo the last undone change", source = CommandSource.BuiltIn), - Command(name = "share", description = "Share the current conversation", source = CommandSource.BuiltIn), - Command( - name = "init", - description = "Initialize OpenCode for this project", - source = CommandSource.BuiltIn - ), - Command(name = "help", description = "Show help information", source = CommandSource.BuiltIn), - Command(name = "connect", description = "Connect to a provider", source = CommandSource.BuiltIn), - Command(name = "bug", description = "Report a bug", source = CommandSource.BuiltIn), + Command(name = "compact", source = CommandSource.BuiltIn), + Command(name = "clear", source = CommandSource.BuiltIn), + Command(name = "new", source = CommandSource.BuiltIn), + Command(name = "undo", source = CommandSource.BuiltIn), + Command(name = "redo", source = CommandSource.BuiltIn), + Command(name = "share", source = CommandSource.BuiltIn), + Command(name = "init", source = CommandSource.BuiltIn), + Command(name = "help", source = CommandSource.BuiltIn), + Command(name = "connect", source = CommandSource.BuiltIn), + Command(name = "bug", source = CommandSource.BuiltIn), ) } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/files/FileExplorerScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/files/FileExplorerScreen.kt index f16b4423..1209afb3 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/files/FileExplorerScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/files/FileExplorerScreen.kt @@ -67,10 +67,10 @@ fun FileExplorerScreen( val uiState by viewModel.uiState.collectAsStateWithLifecycle() val symbolResults by viewModel.symbolResults.collectAsStateWithLifecycle() val uploadState by viewModel.uploadState.collectAsStateWithLifecycle() - var searchQuery by remember { mutableStateOf("") } - var isSearchActive by remember { mutableStateOf(false) } - var isSymbolMode by remember { mutableStateOf(false) } - var symbolQuery by remember { mutableStateOf("") } + val searchQuery = uiState.searchQuery + val isSearchActive = uiState.isSearchActive + val isSymbolMode = uiState.isSymbolMode + val symbolQuery = uiState.symbolQuery var createDialog by remember { mutableStateOf(null) } var renameTarget by remember { mutableStateOf(null) } var deleteTarget by remember { mutableStateOf(null) } @@ -105,10 +105,7 @@ fun FileExplorerScreen( title = "", onNavigateBack = { if (isSearchActive || isSymbolMode) { - isSearchActive = false - isSymbolMode = false - searchQuery = "" - symbolQuery = "" + viewModel.clearFilters() } else if (uiState.currentPath.isNotBlank()) { viewModel.navigateUp() } else { @@ -119,10 +116,7 @@ fun FileExplorerScreen( if (isSymbolMode) { OutlinedTextField( value = symbolQuery, - onValueChange = { - symbolQuery = it - viewModel.searchSymbols(it) - }, + onValueChange = viewModel::updateSymbolQuery, placeholder = { Text( stringResource(R.string.symbol_search_hint), @@ -142,7 +136,7 @@ fun FileExplorerScreen( } else if (isSearchActive) { OutlinedTextField( value = searchQuery, - onValueChange = { searchQuery = it }, + onValueChange = viewModel::updateSearchQuery, placeholder = { Text( stringResource(R.string.files_search_placeholder), @@ -195,7 +189,7 @@ fun FileExplorerScreen( onCreateFolder = { createDialog = FileCreateKind.Folder }, ) IconButton( - onClick = { isSearchActive = true }, + onClick = { viewModel.setSearchActive(true) }, modifier = Modifier.size(Sizing.iconButtonMd) ) { Icon( @@ -206,7 +200,7 @@ fun FileExplorerScreen( ) } IconButton( - onClick = { isSymbolMode = true }, + onClick = { viewModel.setSymbolMode(true) }, modifier = Modifier.size(Sizing.iconButtonMd) ) { Icon( @@ -261,6 +255,25 @@ fun FileExplorerScreen( .fillMaxSize() .padding(padding) ) { + if (uiState.pathRestoreError != null) { + Surface( + modifier = Modifier + .align(Alignment.TopCenter) + .padding(Spacing.md), + color = theme.error.copy(alpha = 0.12f), + shape = RectangleShape, + ) { + Text( + text = stringResource( + R.string.files_restored_path_unavailable, + uiState.pathRestoreError.orEmpty(), + ), + color = theme.error, + modifier = Modifier.padding(Spacing.sm), + ) + } + } + if (isSymbolMode) { // Symbol search results if (symbolQuery.isBlank()) { diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/files/FileViewerScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/files/FileViewerScreen.kt index c0c160cd..d282dba4 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/files/FileViewerScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/files/FileViewerScreen.kt @@ -28,7 +28,7 @@ import dev.blazelight.p4oc.ui.components.code.SyntaxHighlightedCode import dev.blazelight.p4oc.ui.diff.UnifiedDiffBuilder import dev.blazelight.p4oc.ui.screens.files.editor.SoraCodeEditorView import dev.blazelight.p4oc.ui.screens.files.editor.SoraLanguageRegistry -import dev.blazelight.p4oc.ui.screens.files.editor.displayLabelForScope +import dev.blazelight.p4oc.ui.screens.files.editor.displayLabelResForScope import dev.blazelight.p4oc.ui.theme.LocalOpenCodeTheme import dev.blazelight.p4oc.ui.theme.Sizing import dev.blazelight.p4oc.ui.theme.Spacing @@ -57,9 +57,10 @@ fun FileViewerScreen( } val filename = path.substringAfterLast("/") - val languageLabel = remember(filename) { - displayLabelForScope(SoraLanguageRegistry.scopeFor(filename)) + val languageLabelRes = remember(filename) { + displayLabelResForScope(SoraLanguageRegistry.scopeFor(filename)) } + val languageLabel = stringResource(languageLabelRes) val theme = LocalOpenCodeTheme.current val isDirty = editState.isDirty && editMode diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/files/FilesViewModel.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/files/FilesViewModel.kt index 4aaa6fb5..b397cc27 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/files/FilesViewModel.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/files/FilesViewModel.kt @@ -1,5 +1,6 @@ package dev.blazelight.p4oc.ui.screens.files +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dev.blazelight.p4oc.data.files.FileCapabilities @@ -22,28 +23,74 @@ import kotlinx.coroutines.launch class FilesViewModel constructor( private val fileRepository: FileRepository, private val uploadCoordinator: UploadCoordinator, + private val savedStateHandle: SavedStateHandle = SavedStateHandle(), ) : ViewModel() { - private val _uiState = MutableStateFlow(FilesUiState()) + private val _uiState = MutableStateFlow( + FilesUiState( + currentPath = savedStateHandle[KEY_CURRENT_PATH] ?: ROOT_PATH, + searchQuery = savedStateHandle[KEY_SEARCH_QUERY] ?: "", + isSearchActive = savedStateHandle[KEY_SEARCH_ACTIVE] ?: false, + isSymbolMode = savedStateHandle[KEY_SYMBOL_MODE] ?: false, + symbolQuery = savedStateHandle[KEY_SYMBOL_QUERY] ?: "", + ) + ) + private var restoringInitialPath = savedStateHandle.get(KEY_CURRENT_PATH).orEmpty().isNotBlank() + private var pendingPathRestoreError: String? = null val uiState: StateFlow = _uiState.asStateFlow() private val _symbolResults = MutableStateFlow>(emptyList()) val symbolResults: StateFlow> = _symbolResults.asStateFlow() - private val pathStack = mutableListOf() + private val pathStack = savedStateHandle.get>(KEY_PATH_STACK)?.toMutableList() ?: mutableListOf() private var loadFilesJob: Job? = null private var loadContentJob: Job? = null private var saveJob: Job? = null private var mutationJob: Job? = null - private val _editState = MutableStateFlow(FileEditState()) + private val _editState = MutableStateFlow(restoredEditState()) val editState: StateFlow = _editState.asStateFlow() val uploadState: StateFlow = uploadCoordinator.state init { loadCapabilities() - loadFiles(ROOT_PATH) + loadFiles(savedStateHandle[KEY_CURRENT_PATH] ?: ROOT_PATH) + savedStateHandle.get(KEY_SYMBOL_QUERY)?.takeIf { it.isNotBlank() }?.let(::searchSymbols) + } + + private fun restoredEditState(): FileEditState { + val path = savedStateHandle.get(KEY_EDIT_PATH) ?: return FileEditState() + val originalContent = savedStateHandle[KEY_EDIT_ORIGINAL_CONTENT] ?: "" + val currentContent = savedStateHandle[KEY_EDIT_CURRENT_CONTENT] ?: originalContent + return FileEditState( + path = path, + originalContent = originalContent, + currentContent = currentContent, + isDirty = currentContent != originalContent, + contentGeneration = 1, + baselineHash = savedStateHandle[KEY_EDIT_BASELINE_HASH], + ) + } + + private fun persistEditState(state: FileEditState) { + if (state.path == null) { + savedStateHandle.remove(KEY_EDIT_PATH) + savedStateHandle.remove(KEY_EDIT_ORIGINAL_CONTENT) + savedStateHandle.remove(KEY_EDIT_CURRENT_CONTENT) + savedStateHandle.remove(KEY_EDIT_BASELINE_HASH) + return + } + savedStateHandle[KEY_EDIT_PATH] = state.path + savedStateHandle[KEY_EDIT_ORIGINAL_CONTENT] = state.originalContent + savedStateHandle[KEY_EDIT_CURRENT_CONTENT] = state.currentContent + savedStateHandle[KEY_EDIT_BASELINE_HASH] = state.baselineHash + } + + private fun updateEditState(transform: (FileEditState) -> FileEditState) { + _editState.update { current -> + transform(current).also(::persistEditState) + } } fun refresh() { @@ -60,6 +107,49 @@ class FilesViewModel constructor( loadFiles(previousPath) } + fun setSearchActive(active: Boolean) { + savedStateHandle[KEY_SEARCH_ACTIVE] = active + _uiState.update { it.copy(isSearchActive = active) } + } + + fun updateSearchQuery(query: String) { + savedStateHandle[KEY_SEARCH_QUERY] = query + _uiState.update { it.copy(searchQuery = query) } + } + + fun setSymbolMode(active: Boolean) { + savedStateHandle[KEY_SYMBOL_MODE] = active + _uiState.update { it.copy(isSymbolMode = active) } + } + + fun updateSymbolQuery(query: String) { + savedStateHandle[KEY_SYMBOL_QUERY] = query + _uiState.update { it.copy(symbolQuery = query) } + searchSymbols(query) + } + + fun clearFilters() { + savedStateHandle[KEY_SEARCH_ACTIVE] = false + savedStateHandle[KEY_SYMBOL_MODE] = false + savedStateHandle[KEY_SEARCH_QUERY] = "" + savedStateHandle[KEY_SYMBOL_QUERY] = "" + _symbolResults.value = emptyList() + _uiState.update { + it.copy( + isSearchActive = false, + isSymbolMode = false, + searchQuery = "", + symbolQuery = "", + symbolError = null, + ) + } + } + + private fun persistPathState(path: String) { + savedStateHandle[KEY_CURRENT_PATH] = path + savedStateHandle[KEY_PATH_STACK] = ArrayList(pathStack) + } + private fun loadFiles(path: String) { loadFilesJob?.cancel() loadFilesJob = viewModelScope.launch { @@ -67,19 +157,31 @@ class FilesViewModel constructor( when (val result = fileRepository.listFiles(path)) { is FileOperationResult.Ok -> { + val restoreError = pendingPathRestoreError + pendingPathRestoreError = null + restoringInitialPath = false _uiState.update { it.copy( isLoading = false, files = result.data.files, currentPath = result.data.path, + pathRestoreError = restoreError, ) } + persistPathState(result.data.path) } is FileOperationResult.Conflict -> { _uiState.update { it.copy(isLoading = false, error = result.message) } } is FileOperationResult.Failed -> { - _uiState.update { it.copy(isLoading = false, error = result.message) } + if (path != ROOT_PATH && restoringInitialPath) { + pendingPathRestoreError = result.message + pathStack.clear() + loadFiles(ROOT_PATH) + } else { + restoringInitialPath = false + _uiState.update { it.copy(isLoading = false, error = result.message) } + } } } } @@ -120,15 +222,19 @@ class FilesViewModel constructor( // Reset edit baseline whenever we (re)load. The viewer screen owns // the decision of whether to enter edit mode; the baseline is only // consumed when it does. - _editState.update { - FileEditState( - path = path, - originalContent = result.data.content, - currentContent = result.data.content, - isDirty = false, - contentGeneration = it.contentGeneration + 1, - baselineHash = result.data.hash, - ) + updateEditState { current -> + if (current.path == path && current.isDirty) { + current + } else { + FileEditState( + path = path, + originalContent = result.data.content, + currentContent = result.data.content, + isDirty = false, + contentGeneration = current.contentGeneration + 1, + baselineHash = result.data.hash, + ) + } } } is FileOperationResult.Conflict -> { @@ -143,8 +249,8 @@ class FilesViewModel constructor( /** Push the latest text snapshot from the editor into edit state. */ fun onEditorTextChange(newText: String) { - _editState.update { current -> - if (current.path == null) return@update current + updateEditState { current -> + if (current.path == null) return@updateEditState current current.copy( currentContent = newText, isDirty = newText != current.originalContent, @@ -156,10 +262,10 @@ class FilesViewModel constructor( val state = _editState.value if (state.path == null) return if (!state.isDirty) { - _editState.update { it.copy(saveError = null, pendingSavePreview = null) } + updateEditState { it.copy(saveError = null, pendingSavePreview = null) } return } - _editState.update { + updateEditState { it.copy( pendingSavePreview = SavePreview( path = state.path, @@ -172,7 +278,7 @@ class FilesViewModel constructor( } fun dismissSavePreview() { - _editState.update { it.copy(pendingSavePreview = null) } + updateEditState { it.copy(pendingSavePreview = null) } } fun confirmSave() { @@ -181,13 +287,13 @@ class FilesViewModel constructor( fun reloadFromServer() { val path = _editState.value.path ?: return - _editState.update { it.copy(conflict = null) } + updateEditState { it.copy(conflict = null) } loadFileContent(path) } /** Re-issues the write with no baseline hash, suppressing stale-write detection. */ fun overwriteAnyway() { - _editState.update { it.copy(conflict = null) } + updateEditState { it.copy(conflict = null) } performSave(useBaselineHash = false) } @@ -197,12 +303,12 @@ class FilesViewModel constructor( if (state.isSaving) return saveJob?.cancel() saveJob = viewModelScope.launch { - _editState.update { it.copy(isSaving = true, saveError = null) } + updateEditState { it.copy(isSaving = true, saveError = null) } val expected = if (useBaselineHash) state.baselineHash else null val request = FileWriteRequest(path = path, content = state.currentContent, expectedHash = expected) when (val result = fileRepository.writeFile(request)) { is FileOperationResult.Ok -> { - _editState.update { + updateEditState { it.copy( originalContent = state.currentContent, isDirty = false, @@ -223,7 +329,7 @@ class FilesViewModel constructor( _uiState.update { it.copy(fileContent = state.currentContent) } } is FileOperationResult.Conflict -> { - _editState.update { + updateEditState { it.copy( isSaving = false, pendingSavePreview = null, @@ -232,7 +338,7 @@ class FilesViewModel constructor( } } is FileOperationResult.Failed -> { - _editState.update { + updateEditState { it.copy( isSaving = false, pendingSavePreview = null, @@ -245,11 +351,11 @@ class FilesViewModel constructor( } fun dismissConflict() { - _editState.update { it.copy(conflict = null) } + updateEditState { it.copy(conflict = null) } } fun discardEdits() { - _editState.update { state -> + updateEditState { state -> state.copy( currentContent = state.originalContent, isDirty = false, @@ -261,7 +367,7 @@ class FilesViewModel constructor( } fun clearSaveError() { - _editState.update { it.copy(saveError = null) } + updateEditState { it.copy(saveError = null) } } fun uploadFromSources(source: UploadSource, sourceIds: List) { @@ -358,6 +464,16 @@ class FilesViewModel constructor( private companion object { const val ROOT_PATH = "" + const val KEY_CURRENT_PATH = "files_current_path" + const val KEY_PATH_STACK = "files_path_stack" + const val KEY_SEARCH_QUERY = "files_search_query" + const val KEY_SEARCH_ACTIVE = "files_search_active" + const val KEY_SYMBOL_QUERY = "files_symbol_query" + const val KEY_SYMBOL_MODE = "files_symbol_mode" + const val KEY_EDIT_PATH = "files_edit_path" + const val KEY_EDIT_ORIGINAL_CONTENT = "files_edit_original_content" + const val KEY_EDIT_CURRENT_CONTENT = "files_edit_current_content" + const val KEY_EDIT_BASELINE_HASH = "files_edit_baseline_hash" fun childPath(parent: String, child: String): String = listOf(parent.trim('/'), child.trim('/')) .filter { it.isNotBlank() } @@ -373,7 +489,12 @@ data class FilesUiState( val currentPath: String = "", val fileContent: String? = null, val error: String? = null, + val pathRestoreError: String? = null, val symbolError: String? = null, + val searchQuery: String = "", + val isSearchActive: Boolean = false, + val isSymbolMode: Boolean = false, + val symbolQuery: String = "", val capabilities: FileCapabilities = FileCapabilities(), val isMutating: Boolean = false, val mutationMessage: String? = null, diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/files/editor/SoraLanguageRegistry.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/files/editor/SoraLanguageRegistry.kt index d48571ff..2ad238a1 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/files/editor/SoraLanguageRegistry.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/files/editor/SoraLanguageRegistry.kt @@ -1,5 +1,6 @@ package dev.blazelight.p4oc.ui.screens.files.editor +import dev.blazelight.p4oc.R import dev.blazelight.p4oc.core.filetype.FileTypeClassifier /** @@ -29,20 +30,19 @@ internal object SoraLanguageRegistry { } /** - * Human-readable label for [scope], used as the file-viewer subtitle. Returns - * `"plain text"` for null/unknown scopes; only scopes we actually ship are - * mapped here. + * String resource for [scope], used as the file-viewer subtitle. Unknown scopes + * fall back to plain text; only scopes we actually ship are mapped here. */ -internal fun displayLabelForScope(scope: String?): String = when (scope) { - "source.kotlin" -> "kotlin" - "source.json" -> "json" - "source.python" -> "python" - "source.ts" -> "typescript" - "source.yaml" -> "yaml" - "source.toml" -> "toml" - "source.shell" -> "shell" - "source.env" -> "env" - "text.xml" -> "xml" - "text.html.markdown" -> "markdown" - else -> "plain text" +internal fun displayLabelResForScope(scope: String?): Int = when (scope) { + "source.kotlin" -> R.string.file_language_kotlin + "source.json" -> R.string.file_language_json + "source.python" -> R.string.file_language_python + "source.ts" -> R.string.file_language_typescript + "source.yaml" -> R.string.file_language_yaml + "source.toml" -> R.string.file_language_toml + "source.shell" -> R.string.file_language_shell + "source.env" -> R.string.file_language_env + "text.xml" -> R.string.file_language_xml + "text.html.markdown" -> R.string.file_language_markdown + else -> R.string.file_language_plain_text } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerScreen.kt index f288a33a..a92ce0b4 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerScreen.kt @@ -21,9 +21,11 @@ import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextOverflow import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -170,6 +172,11 @@ fun ServerScreen( } } +internal fun serverUrlTextFieldValue(url: String): TextFieldValue = TextFieldValue( + text = url, + selection = TextRange(url.length), +) + @Composable private fun RemoteServerSection( url: String, @@ -185,6 +192,13 @@ private fun RemoteServerSection( ) { val theme = LocalOpenCodeTheme.current var passwordVisible by remember { mutableStateOf(false) } + var urlFieldValue by remember { mutableStateOf(serverUrlTextFieldValue(url)) } + + LaunchedEffect(url) { + if (url != urlFieldValue.text) { + urlFieldValue = serverUrlTextFieldValue(url) + } + } Surface( color = theme.backgroundElement, @@ -209,8 +223,11 @@ private fun RemoteServerSection( ) OutlinedTextField( - value = url, - onValueChange = onUrlChange, + value = urlFieldValue, + onValueChange = { value -> + urlFieldValue = value + onUrlChange(value.text) + }, label = { Text(stringResource(R.string.field_server_url), fontFamily = FontFamily.Monospace) }, placeholder = { Text( stringResource(R.string.field_server_url_placeholder), diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/sessions/SessionListScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/sessions/SessionListScreen.kt index 092702e6..461f7ef9 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/sessions/SessionListScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/sessions/SessionListScreen.kt @@ -234,8 +234,6 @@ fun SessionListScreen( } } } else { - val expandedSessions = remember { mutableStateMapOf() } - // During search, flatten to matching sessions (tree roots only would // hide matching child sessions whose parent is filtered out). val sessionTree = remember(displayedSessions, uiState.searchQuery) { @@ -358,7 +356,7 @@ fun SessionListScreen( SessionTreeNode( node = node, depth = 0, - expandedSessions = expandedSessions, + expandedSessionIds = uiState.expandedSessionIds, sessionStatuses = uiState.sessionStatuses, sessionPresences = uiState.sessionPresences, showProjectChip = filterProjectId == null, @@ -380,7 +378,7 @@ fun SessionListScreen( }, onProjectClick = onProjectClick, onToggleExpand = { id -> - expandedSessions[id] = !(expandedSessions[id] ?: false) + viewModel.toggleSessionExpanded(id) } ) } @@ -568,7 +566,7 @@ private fun buildSessionTree(sessions: List): List, + expandedSessionIds: Set, sessionStatuses: Map, sessionPresences: Map, showProjectChip: Boolean, @@ -583,7 +581,7 @@ private fun SessionTreeNode( ) { val swp = node.sessionWithProject val session = swp.session - val isExpanded = expandedSessions[session.id] ?: false + val isExpanded = session.id in expandedSessionIds val hasChildren = node.children.isNotEmpty() val indentPadding: Dp = Sizing.treeIndent * depth @@ -622,7 +620,7 @@ private fun SessionTreeNode( SessionTreeNode( node = child, depth = depth + 1, - expandedSessions = expandedSessions, + expandedSessionIds = expandedSessionIds, sessionStatuses = sessionStatuses, sessionPresences = sessionPresences, showProjectChip = showProjectChip, diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/sessions/SessionListViewModel.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/sessions/SessionListViewModel.kt index cd8b7d46..89bb4d1f 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/sessions/SessionListViewModel.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/sessions/SessionListViewModel.kt @@ -1,5 +1,6 @@ package dev.blazelight.p4oc.ui.screens.sessions +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dev.blazelight.p4oc.data.remote.dto.ProjectDto @@ -22,6 +23,7 @@ import kotlinx.coroutines.withTimeoutOrNull class SessionListViewModel constructor( private val sessionRepository: SessionRepositoryImpl, + private val savedStateHandle: SavedStateHandle = SavedStateHandle(), ) : ViewModel() { private val _uiState = MutableStateFlow(SessionListUiState()) @@ -30,9 +32,35 @@ class SessionListViewModel constructor( private companion object { const val LOAD_TIMEOUT_MS = 30_000L const val SEARCH_DEBOUNCE_MS = 300L + const val KEY_SEARCH_QUERIES = "session_list_search_queries" + const val KEY_EXPANDED_SESSIONS = "session_list_expanded_sessions" + const val GLOBAL_CONTEXT_KEY = "__global__" } private var searchJob: Job? = null + private val searchQueriesByContext = restoredStringMap(KEY_SEARCH_QUERIES) + private val expandedSessionIdsByContext = restoredStringSetMap(KEY_EXPANDED_SESSIONS) + + private fun restoredStringMap(key: String): MutableMap = + savedStateHandle.get>(key)?.toMutableMap() ?: mutableMapOf() + + private fun restoredStringSetMap(key: String): MutableMap> = + savedStateHandle.get>>(key) + ?.mapValues { (_, value) -> value.toSet() } + ?.toMutableMap() + ?: mutableMapOf() + + private fun persistSearchQueries() { + savedStateHandle[KEY_SEARCH_QUERIES] = HashMap(searchQueriesByContext) + } + + private fun persistExpandedSessions() { + savedStateHandle[KEY_EXPANDED_SESSIONS] = HashMap( + expandedSessionIdsByContext.mapValues { (_, value) -> ArrayList(value) }, + ) + } + + private fun contextKey(directory: String?): String = directory ?: GLOBAL_CONTEXT_KEY init { viewModelScope.launch { @@ -127,6 +155,9 @@ class SessionListViewModel constructor( } fun updateSearchQuery(query: String, directory: String?) { + val key = contextKey(directory) + searchQueriesByContext[key] = query + persistSearchQueries() _uiState.update { state -> state.copy( searchQuery = query, @@ -139,13 +170,33 @@ class SessionListViewModel constructor( } fun updateSearchDirectory(directory: String?) { - val query = _uiState.value.searchQuery - _uiState.update { it.copy(searchDirectory = directory) } - if (query.isNotBlank()) { - searchSessions(query, directory, debounce = false) + val key = contextKey(directory) + val restoredQuery = searchQueriesByContext[key].orEmpty() + val restoredExpanded = expandedSessionIdsByContext[key].orEmpty() + _uiState.update { + it.copy( + searchDirectory = directory, + searchQuery = restoredQuery, + searchResults = emptyList(), + serverSearchQuery = null, + searchError = null, + expandedSessionIds = restoredExpanded, + ) + } + if (restoredQuery.isNotBlank()) { + searchSessions(restoredQuery, directory, debounce = false) } } + fun toggleSessionExpanded(sessionId: String) { + val key = contextKey(_uiState.value.searchDirectory) + val current = _uiState.value.expandedSessionIds + val next = if (sessionId in current) current - sessionId else current + sessionId + expandedSessionIdsByContext[key] = next + persistExpandedSessions() + _uiState.update { it.copy(expandedSessionIds = next) } + } + private fun searchSessions(query: String, directory: String?, debounce: Boolean) { searchJob?.cancel() val trimmed = query.trim() @@ -164,7 +215,13 @@ class SessionListViewModel constructor( searchJob = viewModelScope.launch { if (debounce) delay(SEARCH_DEBOUNCE_MS) _uiState.update { it.copy(isSearching = true, searchError = null) } - val result = runCatching { sessionRepository.searchSessions(trimmed, directory) } + val result = runCatching { + if (directory == null) { + sessionRepository.searchSessionsGlobally(trimmed) + } else { + sessionRepository.searchSessionsInWorkspace(trimmed, directory) + } + } result.fold( onSuccess = { sessions -> _uiState.update { state -> @@ -354,6 +411,7 @@ data class SessionListUiState( val newSessionId: String? = null, val newSessionDirectory: String? = null, val shareUrl: String? = null, + val expandedSessionIds: Set = emptySet(), val error: String? = null ) { val isSearchActive: Boolean diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/SkillsScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/SkillsScreen.kt index 278c30a6..76f046cd 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/SkillsScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/SkillsScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.lifecycle.ViewModel @@ -35,21 +36,44 @@ import org.koin.androidx.compose.koinViewModel data class SkillInfo( val name: String, - val description: String, + val status: String, + val errorDetail: String? = null, val source: String, - val isEnabled: Boolean = true, val tools: List = emptyList(), val resources: List = emptyList(), val prompts: List = emptyList() -) +) { + val isEnabled: Boolean get() = status == MCP_STATUS_CONNECTED +} data class SkillsState( val skills: List = emptyList(), val isLoading: Boolean = false, - val error: String? = null, + val error: SkillsError? = null, val selectedSkill: SkillInfo? = null ) +data class SkillsError( + val kind: SkillsErrorKind, + val detail: String? = null, +) + +enum class SkillsErrorKind { + NotConnected, + ApiError, +} + +internal const val MCP_STATUS_CONNECTED = "connected" + +internal fun mcpStatusDescriptionRes(status: String): Int = when (status) { + MCP_STATUS_CONNECTED -> R.string.skills_status_connected + "disabled" -> R.string.skills_status_disabled + "failed" -> R.string.skills_status_failed + "needs_auth" -> R.string.skills_status_needs_auth + "needs_client_registration" -> R.string.skills_status_needs_client_registration + else -> R.string.skills_status_unknown +} + class SkillsViewModel constructor( private val connectionManager: ConnectionManager ) : ViewModel() { @@ -65,27 +89,23 @@ class SkillsViewModel constructor( viewModelScope.launch { _state.update { it.copy(isLoading = true) } val api = connectionManager.getApi() ?: run { - _state.update { it.copy(isLoading = false, error = "Not connected") } + _state.update { + it.copy( + isLoading = false, + error = SkillsError(SkillsErrorKind.NotConnected), + ) + } return@launch } val result = safeApiCall { api.getMcpStatus() } when (result) { is ApiResult.Success -> { val skills = result.data.map { (name, status) -> - val description = when { - status.error != null -> status.error - status.status == "connected" -> "Connected" - status.status == "disabled" -> "Disabled" - status.status == "failed" -> "Connection failed" - status.status == "needs_auth" -> "Authentication required" - status.status == "needs_client_registration" -> "Client registration required" - else -> status.status.replaceFirstChar { it.uppercase() } - } SkillInfo( name = name, - description = description, + status = status.status, + errorDetail = status.error, source = "mcp", - isEnabled = status.status == "connected", tools = emptyList(), resources = emptyList(), prompts = emptyList() @@ -94,7 +114,12 @@ class SkillsViewModel constructor( _state.update { it.copy(skills = skills, isLoading = false) } } is ApiResult.Error -> { - _state.update { it.copy(isLoading = false, error = result.message) } + _state.update { + it.copy( + isLoading = false, + error = SkillsError(SkillsErrorKind.ApiError, result.message), + ) + } } } } @@ -118,11 +143,17 @@ fun SkillsScreen( val state by viewModel.state.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } + val errorMessage = state.error?.let { error -> + when (error.kind) { + SkillsErrorKind.NotConnected -> stringResource(R.string.skills_error_not_connected) + SkillsErrorKind.ApiError -> error.detail ?: stringResource(R.string.skills_error_generic) + } + } // Show error in snackbar - LaunchedEffect(state.error) { - state.error?.let { error -> + LaunchedEffect(errorMessage) { + errorMessage?.let { message -> snackbarHostState.showSnackbar( - message = error, + message = message, duration = SnackbarDuration.Short ) viewModel.clearError() @@ -310,25 +341,41 @@ private fun SkillCard( ) } } + val statusDescription = stringResource(mcpStatusDescriptionRes(skill.status)) Text( - text = skill.description, + text = statusDescription, style = MaterialTheme.typography.bodySmall, color = theme.textMuted ) + skill.errorDetail?.let { detail -> + Text( + text = detail, + style = MaterialTheme.typography.labelSmall, + color = theme.error + ) + } if (skill.tools.isNotEmpty() || skill.resources.isNotEmpty()) { Spacer(Modifier.height(Spacing.xs)) Row(horizontalArrangement = Arrangement.spacedBy(Spacing.md)) { if (skill.tools.isNotEmpty()) { Text( - text = "${skill.tools.size} tools", + text = pluralStringResource( + R.plurals.skills_tools_count, + skill.tools.size, + skill.tools.size, + ), style = MaterialTheme.typography.labelSmall, color = theme.accent ) } if (skill.resources.isNotEmpty()) { Text( - text = "${skill.resources.size} resources", + text = pluralStringResource( + R.plurals.skills_resources_count, + skill.resources.size, + skill.resources.size, + ), style = MaterialTheme.typography.labelSmall, color = theme.secondary ) @@ -359,7 +406,10 @@ private fun SkillDetailDialog( } } ) { - Text(skill.description) + Text(stringResource(mcpStatusDescriptionRes(skill.status))) + skill.errorDetail?.let { detail -> + Text(detail) + } Row( horizontalArrangement = Arrangement.spacedBy(Spacing.md), diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/terminal/TerminalScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/terminal/TerminalScreen.kt index 9931415c..b901b9dc 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/terminal/TerminalScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/terminal/TerminalScreen.kt @@ -117,6 +117,7 @@ fun TerminalScreen( onCtrlToggle = { ctrlActive = !ctrlActive }, onAltToggle = { altActive = !altActive }, enabled = uiState.isConnected, + onPaste = { currentTerminalView?.mTermSession?.onPasteTextFromClipboard() }, modifier = Modifier.fillMaxWidth() ) } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/terminal/TerminalViewModel.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/terminal/TerminalViewModel.kt index edbc6bc3..240f14e6 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/terminal/TerminalViewModel.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/terminal/TerminalViewModel.kt @@ -32,7 +32,7 @@ import kotlinx.coroutines.launch * Each terminal tab gets its own instance with its own ptyId and websocket connection. */ class TerminalViewModel constructor( - savedStateHandle: SavedStateHandle, + private val savedStateHandle: SavedStateHandle, private val context: Context, private val connectionManager: ConnectionManager, private val ptyWebSocket: PtyWebSocketClient @@ -44,12 +44,22 @@ class TerminalViewModel constructor( private const val DEFAULT_COLS = 80 private const val TRANSCRIPT_ROWS = 2000 private const val RESIZE_DEBOUNCE_MS = 150L + private const val MAX_SAVED_TRANSCRIPT_CHARS = 64 * 1024 + private const val KEY_TRANSCRIPT = "terminal_transcript" + private const val KEY_TITLE = "terminal_title" + private const val KEY_EXITED = "terminal_exited" + private const val KEY_RESTORED_MISSING = "terminal_restored_missing" } val ptyId: String = savedStateHandle.get(Screen.Terminal.ARG_PTY_ID) ?: throw IllegalArgumentException("ptyId is required for TerminalViewModel") - private val _uiState = MutableStateFlow(TerminalUiState()) + private val _uiState = MutableStateFlow( + TerminalUiState( + title = savedStateHandle[KEY_TITLE], + isExited = savedStateHandle[KEY_EXITED] ?: false, + ) + ) val uiState: StateFlow = _uiState.asStateFlow() private val _terminalInvalidations = MutableSharedFlow(extraBufferCapacity = 64) @@ -78,6 +88,7 @@ class TerminalViewModel constructor( init { initEmulator() + replayRestoredTranscript() fetchPtyDetails() connectToSession() observeEvents() @@ -107,6 +118,21 @@ class TerminalViewModel constructor( } } } + private fun replayRestoredTranscript() { + val transcript = savedStateHandle.get(KEY_TRANSCRIPT).orEmpty() + if (transcript.isEmpty()) return + val bytes = transcript.toByteArray() + emulator?.append(bytes, bytes.size) + requestTerminalInvalidation() + } + + private fun appendAndPersist(chunk: String) { + val bytes = chunk.toByteArray() + emulator?.append(bytes, bytes.size) + val current = savedStateHandle.get(KEY_TRANSCRIPT).orEmpty() + savedStateHandle[KEY_TRANSCRIPT] = (current + chunk).takeLast(MAX_SAVED_TRANSCRIPT_CHARS) + requestTerminalInvalidation() + } private fun fetchPtyDetails() { viewModelScope.launch { @@ -115,8 +141,18 @@ class TerminalViewModel constructor( when (result) { is ApiResult.Success -> { val pty = result.data.find { it.id == ptyId } - pty?.let { - _uiState.update { state -> state.copy(title = it.title) } + if (pty == null) { + savedStateHandle[KEY_RESTORED_MISSING] = true + _uiState.update { + it.copy( + error = "Terminal session is no longer available", + isConnected = false, + isConnecting = false, + ) + } + } else { + savedStateHandle[KEY_TITLE] = pty.title + _uiState.update { state -> state.copy(title = pty.title) } } } is ApiResult.Error -> { @@ -173,10 +209,7 @@ class TerminalViewModel constructor( private fun observeWebSocketOutput() { viewModelScope.launch { ptyWebSocket.output.collect { data -> - val em = emulator ?: return@collect - val bytes = data.toByteArray() - em.append(bytes, bytes.size) - requestTerminalInvalidation() + appendAndPersist(data) } } } @@ -218,15 +251,15 @@ class TerminalViewModel constructor( when (val event = scopedEvent.event) { is OpenCodeEvent.PtyUpdated -> { if (event.pty.id == ptyId) { + savedStateHandle[KEY_TITLE] = event.pty.title _uiState.update { it.copy(title = event.pty.title) } } } is OpenCodeEvent.PtyExited -> { if (event.id == ptyId) { val exitMessage = "\r\n[Process exited with code ${event.exitCode}]\r\n" - val bytes = exitMessage.toByteArray() - emulator?.append(bytes, bytes.size) - requestTerminalInvalidation() + appendAndPersist(exitMessage) + savedStateHandle[KEY_EXITED] = true _uiState.update { it.copy(isExited = true) } } } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/MainTabScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/MainTabScreen.kt index 42f2e264..ebbc1d8b 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/MainTabScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/MainTabScreen.kt @@ -128,6 +128,7 @@ fun MainTabScreen( settingsDataStore.setPersistedTabState(state) } + val tabTitleLabels = rememberTabTitleLabels() // Build tab titles and icons from current routes (updated inside pager pages). // Seed from startRoute so titles are correct even when pages are off-screen. val tabTitles = remember { mutableStateMapOf() } @@ -136,6 +137,7 @@ fun MainTabScreen( if (tab.id !in tabTitles) { tabTitles[tab.id] = getTitleForRoute( route = tab.startRoute, + labels = tabTitleLabels, sessionTitle = tab.sessionTitle, workspaceKey = tab.workspaceKey, ) @@ -388,7 +390,9 @@ fun MainTabScreen( snackbarHostState.showSnackbar("Not connected to server") return@launch } - val result = safeApiCall { api.createPtySession(CreatePtyRequest()) } + val result = safeApiCall { + api.createPtySession(createPtyRequestForWorkspace(globalWorkspaceKey)) + } when (result) { is ApiResult.Success -> { val ptyId = result.data.id @@ -458,6 +462,7 @@ fun MainTabScreen( if (route != null) { tabTitles[tab.id] = getTitleForRoute( route = route, + labels = tabTitleLabels, sessionTitle = tab.sessionTitle, workspaceKey = tab.workspaceKey, ) @@ -500,12 +505,7 @@ fun MainTabScreen( return@launch } val result = safeApiCall { - api.createPtySession( - CreatePtyRequest( - cwd = (workspaceKey as? WorkspaceKey.Directory)?.value, - title = terminalTitle(workspaceKey), - ) - ) + api.createPtySession(createPtyRequestForWorkspace(workspaceKey)) } when (result) { is ApiResult.Success -> { @@ -579,7 +579,7 @@ fun MainTabScreen( ) openWorkspaceKeys.forEach { workspaceKey -> FilesWorkspaceOption( - title = workspaceLabel(workspaceKey) ?: "Missing workspace", + title = workspaceLabel(workspaceKey, tabTitleLabels) ?: "Missing workspace", subtitle = workspaceSubtitle(workspaceKey), marker = "◇", onClick = { openFilesTab(workspaceKey) }, @@ -588,6 +588,11 @@ fun MainTabScreen( } } } +internal fun createPtyRequestForWorkspace(workspaceKey: WorkspaceKey): CreatePtyRequest = CreatePtyRequest( + cwd = (workspaceKey as? WorkspaceKey.Directory)?.value, + title = terminalTitle(workspaceKey), +) + private fun terminalTitle(workspaceKey: WorkspaceKey): String? = when (workspaceKey) { is WorkspaceKey.Directory -> workspaceKey.value.trimEnd('/').substringAfterLast('/').ifBlank { "Terminal" } WorkspaceKey.Global -> null diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabBar.kt b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabBar.kt index 2feae136..0d6e862d 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabBar.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabBar.kt @@ -16,8 +16,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.style.TextOverflow +import dev.blazelight.p4oc.R import dev.blazelight.p4oc.domain.model.SessionConnectionState import dev.blazelight.p4oc.domain.model.SessionPresence import dev.blazelight.p4oc.domain.server.WorkspaceKey @@ -204,34 +206,62 @@ fun getIconForRoute(route: String?): ImageVector { /** * Helper to get appropriate title for a screen route. */ +data class TabTitleLabels( + val fallbackTab: String, + val sessions: String, + val chat: String, + val files: String, + val file: String, + val terminal: String, + val settings: String, + val projects: String, + val globalWorkspace: String, + val sessionWorkspace: String, +) + +@Composable +fun rememberTabTitleLabels(): TabTitleLabels = TabTitleLabels( + fallbackTab = stringResource(R.string.tab_title_fallback), + sessions = stringResource(R.string.sessions_title), + chat = stringResource(R.string.tab_title_chat), + files = stringResource(R.string.tab_title_files), + file = stringResource(R.string.tab_title_file), + terminal = stringResource(R.string.terminal_title), + settings = stringResource(R.string.settings_title), + projects = stringResource(R.string.tab_title_projects), + globalWorkspace = stringResource(R.string.tab_workspace_global), + sessionWorkspace = stringResource(R.string.tab_workspace_session), +) + fun getTitleForRoute( route: String?, + labels: TabTitleLabels, sessionTitle: String? = null, workspaceKey: WorkspaceKey? = null, ): String { return when { - route == null -> "Tab" - route == "sessions" -> withWorkspaceSuffix("Sessions", workspaceKey) - route.startsWith("sessions?") -> withWorkspaceSuffix("Sessions", workspaceKey) - route.startsWith("chat/") -> withWorkspaceSuffix(sessionTitle ?: "Chat", workspaceKey) - route == "files" -> workspaceLabel(workspaceKey) ?: "Files" - route.startsWith("files/") -> workspaceLabel(workspaceKey) ?: "File" - route.startsWith("terminal/") -> withWorkspaceSuffix(sessionTitle ?: "Terminal", workspaceKey) - route == "settings" -> "Settings" - route.startsWith("settings/") -> "Settings" - route == "projects" -> "Projects" - else -> "Tab" + route == null -> labels.fallbackTab + route == "sessions" -> withWorkspaceSuffix(labels.sessions, workspaceKey, labels) + route.startsWith("sessions?") -> withWorkspaceSuffix(labels.sessions, workspaceKey, labels) + route.startsWith("chat/") -> withWorkspaceSuffix(sessionTitle ?: labels.chat, workspaceKey, labels) + route == "files" -> workspaceLabel(workspaceKey, labels) ?: labels.files + route.startsWith("files/") -> workspaceLabel(workspaceKey, labels) ?: labels.file + route.startsWith("terminal/") -> withWorkspaceSuffix(sessionTitle ?: labels.terminal, workspaceKey, labels) + route == "settings" -> labels.settings + route.startsWith("settings/") -> labels.settings + route == "projects" -> labels.projects + else -> labels.fallbackTab } } -private fun withWorkspaceSuffix(title: String, workspaceKey: WorkspaceKey?): String { - val workspace = workspaceLabel(workspaceKey) ?: return title +private fun withWorkspaceSuffix(title: String, workspaceKey: WorkspaceKey?, labels: TabTitleLabels): String { + val workspace = workspaceLabel(workspaceKey, labels) ?: return title return "$title · $workspace" } -fun workspaceLabel(workspaceKey: WorkspaceKey?): String? = when (workspaceKey) { +fun workspaceLabel(workspaceKey: WorkspaceKey?, labels: TabTitleLabels): String? = when (workspaceKey) { is WorkspaceKey.Directory -> workspaceKey.value.trimEnd('/').substringAfterLast('/').ifBlank { workspaceKey.value } - WorkspaceKey.Global -> "Global" - is WorkspaceKey.SessionScoped -> "Session" + WorkspaceKey.Global -> labels.globalWorkspace + is WorkspaceKey.SessionScoped -> labels.sessionWorkspace null -> null } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5d985864..f2744444 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -196,6 +196,28 @@ Message is empty queued + Compact the conversation to reduce context size + Clear the conversation history + Start a new conversation + Undo the last change + Redo the last undone change + Share the current conversation + Initialize OpenCode for this project + Show help information + Connect to a provider + Report a bug + Loading workspace commands... + No commands match %1$s + Retry loading commands + + + Tab + Chat + Files + File + Projects + Global + Session Back @@ -229,6 +251,7 @@ -- no matching files -- -- empty folder -- Symbol search failed: %1$s + Restored path is unavailable; showing workspace root. %1$s Create New file New folder @@ -355,6 +378,22 @@ Resources No skills configured MCP servers will appear here once configured + Connected + Disabled + Connection failed + Authentication required + Client registration required + Unknown status + Not connected + Unable to load skills + + %d tool + %d tools + + + %d resource + %d resources + Provider Configuration @@ -573,6 +612,11 @@ Progress %1$d / %2$d %d%% complete + pending + in progress + completed + cancelled + unknown No active todos The agent hasn\'t created any tasks yet @@ -696,6 +740,14 @@ Permission Required + User Input Required + Notifications when AI needs your input + Assistant completed + Notifications when the assistant finishes a response + Question from AI + AI has a question + Assistant finished + Response complete Notification permission was denied. To receive notifications when the AI needs your input, enable notifications in your device settings. Open Settings Notification permission not granted @@ -734,6 +786,19 @@ [ Upload complete ] Uploading %1$d of %2$d: %3$s — %4$s / %5$s %1$d uploaded · %2$d failed + + kotlin + json + python + typescript + yaml + toml + shell + env + xml + markdown + plain text + Cancel Dismiss Retry failed diff --git a/app/src/test/java/dev/blazelight/p4oc/core/network/ConnectionManagerFallbackTest.kt b/app/src/test/java/dev/blazelight/p4oc/core/network/ConnectionManagerFallbackTest.kt new file mode 100644 index 00000000..0daf8944 --- /dev/null +++ b/app/src/test/java/dev/blazelight/p4oc/core/network/ConnectionManagerFallbackTest.kt @@ -0,0 +1,64 @@ +package dev.blazelight.p4oc.core.network + +import dev.blazelight.p4oc.core.datastore.SettingsDataStore +import dev.blazelight.p4oc.data.remote.mapper.EventMapper +import dev.blazelight.p4oc.data.remote.mapper.MessageMapper +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.serialization.json.Json +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ConnectionManagerFallbackTest { + private val dispatcher = StandardTestDispatcher() + private val json = Json { ignoreUnknownKeys = true } + private lateinit var manager: ConnectionManager + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + manager = ConnectionManager( + json = json, + eventMapper = EventMapper(json, MessageMapper(json)), + settingsDataStore = mockk(), + ) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `explicit opencode port only tries the primary url`() { + val primary = ServerConfig(url = "http://192.168.24.25:4096") + + val candidates = manager.connectionCandidates(primary) + + assertTrue(manager.hasExplicitPort(primary.url)) + assertEquals(listOf(primary), candidates) + } + + @Test + fun `implicit http opencode port can fall back to port 80`() { + val primary = ServerConfig(url = "http://192.168.24.25") + + val candidates = manager.connectionCandidates(primary) + + assertFalse(manager.hasExplicitPort(primary.url)) + assertEquals(2, candidates.size) + assertEquals(primary.copy(url = "http://192.168.24.25:4096"), candidates[0]) + assertEquals(primary.copy(url = "http://192.168.24.25"), candidates[1]) + assertEquals(80, candidates[1].url.toHttpUrl().port) + } +} diff --git a/app/src/test/java/dev/blazelight/p4oc/data/session/SessionRepositoryImplTest.kt b/app/src/test/java/dev/blazelight/p4oc/data/session/SessionRepositoryImplTest.kt index 25cd285f..b3d9e977 100644 --- a/app/src/test/java/dev/blazelight/p4oc/data/session/SessionRepositoryImplTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/data/session/SessionRepositoryImplTest.kt @@ -203,6 +203,74 @@ class SessionRepositoryImplTest { assertEquals("/repo/p1", sessions.getValue("same").session.directory) } + @Test + fun `searchSessionsInWorkspace searches only requested directory`() = runTest { + val client = FakeWorkspaceClient().apply { + projects = listOf( + FakeWorkspaceClient.projectDto("a", "/repo/a"), + FakeWorkspaceClient.projectDto("b", "/repo/b"), + ) + sessionsByDirectoryAndSearch = mapOf( + Pair("/repo/a", "match") to listOf( + FakeWorkspaceClient.sessionDto(id = "in-a", title = "match in a", directory = "/repo/a") + ), + Pair("/repo/b", "match") to listOf( + FakeWorkspaceClient.sessionDto(id = "in-b", title = "match in b", directory = "/repo/b") + ), + ) + } + val repository = + SessionRepositoryImpl( + client, + nowMs = { testScheduler.currentTime }, + dispatcher = StandardTestDispatcher(testScheduler) + ) + + val results = repository.searchSessionsInWorkspace("match", "/repo/a") + + assertEquals(listOf("/repo/a"), client.listSessionsDirectories) + assertEquals(listOf("match"), client.listSessionsCallsLog.map { it.search }) + assertEquals(listOf("in-a"), results.map { it.id.value }) + assertEquals(listOf("/repo/a"), results.map { it.session.directory }) + } + + @Test + fun `searchSessionsGlobally searches global and project worktrees and dedupes results`() = runTest { + val client = FakeWorkspaceClient().apply { + projects = listOf( + FakeWorkspaceClient.projectDto("a", "/repo/a"), + FakeWorkspaceClient.projectDto("b", "/repo/b"), + ) + sessionsByDirectoryAndSearch = mapOf( + Pair(null, "match") to listOf( + FakeWorkspaceClient.sessionDto(id = "global", title = "global match", directory = "/global", updatedAt = 3L), + FakeWorkspaceClient.sessionDto(id = "shared", title = "older match", directory = "/global", updatedAt = 1L), + ), + Pair("/repo/a", "match") to listOf( + FakeWorkspaceClient.sessionDto(id = "repo-a", title = "repo a match", directory = "/repo/a", updatedAt = 5L), + FakeWorkspaceClient.sessionDto(id = "shared", title = "newer match", directory = "/repo/a", updatedAt = 5L) + ), + Pair("/repo/b", "match") to listOf( + FakeWorkspaceClient.sessionDto(id = "repo-b", title = "repo b match", directory = "/repo/b", updatedAt = 4L) + ), + ) + } + val repository = + SessionRepositoryImpl( + client, + nowMs = { testScheduler.currentTime }, + dispatcher = StandardTestDispatcher(testScheduler) + ) + + val results = repository.searchSessionsGlobally("match") + + assertEquals(1, client.listProjectsCalls) + assertEquals(listOf(null, "/repo/a", "/repo/b"), client.listSessionsDirectories) + assertEquals(listOf("match", "match", "match"), client.listSessionsCallsLog.map { it.search }) + assertEquals(listOf("repo-a", "repo-b", "global", "shared"), results.map { it.id.value }) + assertEquals(listOf("/repo/a", "/repo/b", "/global", "/global"), results.map { it.session.directory }) + } + @Test fun `hydrate filters OFISH sessions from visible state`() = runTest { val client = FakeWorkspaceClient().apply { diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/components/chat/SlashCommandsPopupTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/components/chat/SlashCommandsPopupTest.kt new file mode 100644 index 00000000..34b41285 --- /dev/null +++ b/app/src/test/java/dev/blazelight/p4oc/ui/components/chat/SlashCommandsPopupTest.kt @@ -0,0 +1,108 @@ +package dev.blazelight.p4oc.ui.components.chat + +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import dev.blazelight.p4oc.domain.model.Command +import dev.blazelight.p4oc.domain.model.CommandSource +import org.junit.Assert.assertEquals +import org.junit.Test + +class SlashCommandsPopupTest { + @Test + fun `filter slash commands returns all commands for empty slash filter`() { + val commands = listOf( + Command(name = "compact", description = "Summarize this session", source = CommandSource.BuiltIn), + Command(name = "deploy", description = "Ship the current workspace", source = CommandSource.Custom), + Command(name = "review", description = "Run a code review", source = CommandSource.Skill), + ) + + assertEquals(commands, filterSlashCommands(commands, "")) + assertEquals(commands, filterSlashCommands(commands, "/")) + } + + @Test + fun `filter slash commands filters by command name`() { + val commands = listOf( + Command(name = "compact", description = "Summarize this session", source = CommandSource.BuiltIn), + Command(name = "deploy", description = "Ship the current workspace", source = CommandSource.Custom), + Command(name = "review", description = "Run a code review", source = CommandSource.Skill), + ) + + assertEquals( + listOf(commands[1]), + filterSlashCommands(commands, "/dep") + ) + assertEquals( + listOf(commands[0]), + filterSlashCommands(commands, "COM") + ) + } + + @Test + fun `filter slash commands filters by resolved description text`() { + val commands = listOf( + Command(name = "compact", description = "Summarize this session", source = CommandSource.BuiltIn), + Command(name = "deploy", description = "Ship the current workspace", source = CommandSource.Custom), + Command(name = "review", description = "Run a code review", source = CommandSource.Skill), + Command(name = "empty", description = null, source = CommandSource.Custom), + ) + + assertEquals( + listOf(commands[0]), + filterSlashCommands(commands, "/summarize") + ) + assertEquals( + listOf(commands[2]), + filterSlashCommands(commands, "CODE REVIEW") + ) + } + + @Test + fun `slash command source compact label returns expected labels`() { + assertEquals("[bi]", slashCommandSourceCompactLabel(CommandSource.BuiltIn)) + assertEquals("[skill]", slashCommandSourceCompactLabel(CommandSource.Skill)) + assertEquals("[mcp]", slashCommandSourceCompactLabel(CommandSource.Mcp)) + assertEquals("[custom]", slashCommandSourceCompactLabel(CommandSource.Custom)) + assertEquals("[sub]", slashCommandSourceCompactLabel(CommandSource.Subtask)) + } + + @Test + fun `above anchor popup position provider positions popup above anchor`() { + val position = AboveAnchorPopupPositionProvider().calculatePosition( + anchorBounds = IntRect(left = 40, top = 160, right = 240, bottom = 200), + windowSize = IntSize(width = 300, height = 500), + layoutDirection = LayoutDirection.Ltr, + popupContentSize = IntSize(width = 120, height = 90), + ) + + assertEquals(40, position.x) + assertEquals(70, position.y) + } + + @Test + fun `above anchor popup position provider clamps x within window`() { + val position = AboveAnchorPopupPositionProvider().calculatePosition( + anchorBounds = IntRect(left = 260, top = 160, right = 300, bottom = 200), + windowSize = IntSize(width = 300, height = 500), + layoutDirection = LayoutDirection.Ltr, + popupContentSize = IntSize(width = 120, height = 90), + ) + + assertEquals(180, position.x) + assertEquals(70, position.y) + } + + @Test + fun `above anchor popup position provider clamps y to zero when not enough space above`() { + val position = AboveAnchorPopupPositionProvider().calculatePosition( + anchorBounds = IntRect(left = 40, top = 50, right = 240, bottom = 90), + windowSize = IntSize(width = 300, height = 500), + layoutDirection = LayoutDirection.Ltr, + popupContentSize = IntSize(width = 120, height = 90), + ) + + assertEquals(40, position.x) + assertEquals(0, position.y) + } +} diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/components/command/CommandMetadataTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/components/command/CommandMetadataTest.kt new file mode 100644 index 00000000..7ed91650 --- /dev/null +++ b/app/src/test/java/dev/blazelight/p4oc/ui/components/command/CommandMetadataTest.kt @@ -0,0 +1,57 @@ +package dev.blazelight.p4oc.ui.components.command + +import dev.blazelight.p4oc.R +import dev.blazelight.p4oc.domain.model.Command +import dev.blazelight.p4oc.domain.model.CommandSource +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class CommandMetadataTest { + @Test + fun `built in command names map to localized description resources`() { + val expected = mapOf( + "compact" to R.string.slash_command_compact_desc, + "clear" to R.string.slash_command_clear_desc, + "new" to R.string.slash_command_new_desc, + "undo" to R.string.slash_command_undo_desc, + "redo" to R.string.slash_command_redo_desc, + "share" to R.string.slash_command_share_desc, + "init" to R.string.slash_command_init_desc, + "help" to R.string.slash_command_help_desc, + "connect" to R.string.slash_command_connect_desc, + "bug" to R.string.slash_command_bug_desc, + ) + + expected.forEach { (name, descriptionRes) -> + assertEquals(descriptionRes, builtInCommandDescriptionRes(name)) + } + } + + @Test + fun `unknown command name has no built in description resource`() { + assertNull(builtInCommandDescriptionRes("deploy")) + } + + @Test + fun `built in descriptions resolve without overwriting upstream metadata`() { + val commands = listOf( + Command(name = "compact", source = CommandSource.BuiltIn), + Command(name = "custom-cmd", description = "My custom desc", source = CommandSource.Custom), + Command(name = "mcp-tool", description = "From server", source = CommandSource.Mcp), + Command(name = "skill-cmd", description = "From skill", source = CommandSource.Skill), + Command(name = "future-built-in", description = "Keep fallback", source = CommandSource.BuiltIn), + ) + + val resolved = resolveBuiltInCommandDescriptions( + commands = commands, + builtInDescriptions = mapOf("compact" to "Compact from resources"), + ) + + assertEquals("Compact from resources", resolved[0].description) + assertEquals("My custom desc", resolved[1].description) + assertEquals("From server", resolved[2].description) + assertEquals("From skill", resolved[3].description) + assertEquals("Keep fallback", resolved[4].description) + } +} diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/components/todo/TodoTrackerMetadataTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/components/todo/TodoTrackerMetadataTest.kt new file mode 100644 index 00000000..ce701970 --- /dev/null +++ b/app/src/test/java/dev/blazelight/p4oc/ui/components/todo/TodoTrackerMetadataTest.kt @@ -0,0 +1,26 @@ +package dev.blazelight.p4oc.ui.components.todo + +import dev.blazelight.p4oc.R +import org.junit.Assert.assertEquals +import org.junit.Test + +class TodoTrackerMetadataTest { + @Test + fun `known todo statuses map to localized label resources`() { + val expected = mapOf( + TODO_STATUS_PENDING to R.string.todo_status_pending, + TODO_STATUS_IN_PROGRESS to R.string.todo_status_in_progress, + TODO_STATUS_COMPLETED to R.string.todo_status_completed, + TODO_STATUS_CANCELLED to R.string.todo_status_cancelled, + ) + + expected.forEach { (status, labelRes) -> + assertEquals(labelRes, todoStatusLabelRes(status)) + } + } + + @Test + fun `unknown todo status maps to unknown label resource`() { + assertEquals(R.string.todo_status_unknown, todoStatusLabelRes("blocked")) + } +} diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/screens/files/FilesViewModelEditTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/screens/files/FilesViewModelEditTest.kt index 7aa383a7..4d093f29 100644 --- a/app/src/test/java/dev/blazelight/p4oc/ui/screens/files/FilesViewModelEditTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/ui/screens/files/FilesViewModelEditTest.kt @@ -1,5 +1,6 @@ package dev.blazelight.p4oc.ui.screens.files +import androidx.lifecycle.SavedStateHandle import dev.blazelight.p4oc.data.files.FileCapabilities import dev.blazelight.p4oc.data.files.FileList import dev.blazelight.p4oc.data.files.FileOperationResult @@ -134,22 +135,107 @@ class FilesViewModelEditTest { assertNull(vm.editState.value.pendingSavePreview) } + @Test + fun recreateWithSameSavedStateHandle_restoresDirtyEditBuffer() = runTest { + val savedStateHandle = SavedStateHandle() + val repo = FakeRepo(content = "original", hash = "hash-1") + val first = FilesViewModel(repo, testUploadCoordinator(repo), savedStateHandle) + first.loadFileContent("src/App.kt") + + first.onEditorTextChange("changed") + + val recreated = FilesViewModel(repo, testUploadCoordinator(repo), savedStateHandle) + val edit = recreated.editState.value + + assertEquals("src/App.kt", edit.path) + assertEquals("original", edit.originalContent) + assertEquals("changed", edit.currentContent) + assertEquals("hash-1", edit.baselineHash) + assertTrue(edit.isDirty) + } + + @Test + fun loadFileContent_preservesRestoredDirtyBufferForSamePath() = runTest { + val savedStateHandle = SavedStateHandle() + val repo = FakeRepo(content = "original", hash = "hash-1") + val first = FilesViewModel(repo, testUploadCoordinator(repo), savedStateHandle) + first.loadFileContent("src/App.kt") + first.onEditorTextChange("changed") + + val recreated = FilesViewModel(repo, testUploadCoordinator(repo), savedStateHandle) + recreated.loadFileContent("src/App.kt") + + assertEquals("changed", recreated.editState.value.currentContent) + assertTrue(recreated.editState.value.isDirty) + } + + @Test + fun recreateWithSameSavedStateHandle_restoresPathStackAndFilters() = runTest { + val savedStateHandle = SavedStateHandle() + val repo = FakeRepo(content = "") + val first = FilesViewModel(repo, testUploadCoordinator(repo), savedStateHandle) + + first.navigateTo("src") + first.navigateTo("src/main") + first.setSearchActive(true) + first.updateSearchQuery("view") + first.setSymbolMode(true) + first.updateSymbolQuery("Main") + + val recreated = FilesViewModel(repo, testUploadCoordinator(repo), savedStateHandle) + + assertEquals("src/main", recreated.uiState.value.currentPath) + assertTrue(recreated.uiState.value.isSearchActive) + assertEquals("view", recreated.uiState.value.searchQuery) + assertTrue(recreated.uiState.value.isSymbolMode) + assertEquals("Main", recreated.uiState.value.symbolQuery) + assertEquals(listOf("Main", "Main"), repo.symbolQueries) + + recreated.navigateUp() + + assertEquals("src", recreated.uiState.value.currentPath) + } + + @Test + fun missingRestoredPathFallsBackToRootWithRestoreError() = runTest { + val savedStateHandle = SavedStateHandle( + mapOf( + "files_current_path" to "missing", + "files_path_stack" to arrayListOf(""), + ) + ) + val repo = FakeRepo(content = "", failedPaths = setOf("missing")) + + val vm = FilesViewModel(repo, testUploadCoordinator(repo), savedStateHandle) + + assertEquals("", vm.uiState.value.currentPath) + assertEquals("missing path", vm.uiState.value.pathRestoreError) + } + private class FakeRepo( val content: String, val hash: String? = null, + val failedPaths: Set = emptySet(), val writeResult: FileOperationResult = FileOperationResult.Ok(FileWriteResult("p", hash = null)), ) : FileRepository { val writes = mutableListOf() + val symbolQueries = mutableListOf() override suspend fun listFiles(path: String): FileOperationResult = - FileOperationResult.Ok(FileList(path, emptyList())) + if (path in failedPaths) { + FileOperationResult.Failed("missing path") + } else { + FileOperationResult.Ok(FileList(path, emptyList())) + } override suspend fun readFile(path: String): FileOperationResult = FileOperationResult.Ok(FileContent(content = content, hash = hash)) - override suspend fun searchSymbols(query: String): FileOperationResult> = - FileOperationResult.Ok(emptyList()) + override suspend fun searchSymbols(query: String): FileOperationResult> { + symbolQueries += query + return FileOperationResult.Ok(emptyList()) + } override suspend fun writeFile(request: FileWriteRequest): FileOperationResult { writes += request diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/screens/files/editor/SoraLanguageRegistryTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/screens/files/editor/SoraLanguageRegistryTest.kt index bb4da994..ea9888b5 100644 --- a/app/src/test/java/dev/blazelight/p4oc/ui/screens/files/editor/SoraLanguageRegistryTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/ui/screens/files/editor/SoraLanguageRegistryTest.kt @@ -1,5 +1,6 @@ package dev.blazelight.p4oc.ui.screens.files.editor +import dev.blazelight.p4oc.R import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test @@ -77,6 +78,27 @@ class SoraLanguageRegistryTest { assertEquals("text.xml", SoraLanguageRegistry.scopeFor("layout.XML")) } + @Test + fun `display labels map known scopes to language resources`() { + val cases = mapOf( + "source.kotlin" to R.string.file_language_kotlin, + "text.html.markdown" to R.string.file_language_markdown, + ) + + cases.forEach { (scope, expected) -> + assertEquals("label resource for $scope", expected, displayLabelResForScope(scope)) + } + } + + @Test + fun `display labels fall back to plain text for null and unknown scopes`() { + assertEquals(R.string.file_language_plain_text, displayLabelResForScope(null)) + assertEquals( + R.string.file_language_plain_text, + displayLabelResForScope("source.language-we-do-not-ship"), + ) + } + @Test fun `unknown extensions return null`() { assertNull(SoraLanguageRegistry.scopeFor("photo.jpg")) diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/screens/server/ServerUrlTextFieldValueTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/screens/server/ServerUrlTextFieldValueTest.kt new file mode 100644 index 00000000..1e41565b --- /dev/null +++ b/app/src/test/java/dev/blazelight/p4oc/ui/screens/server/ServerUrlTextFieldValueTest.kt @@ -0,0 +1,28 @@ +package dev.blazelight.p4oc.ui.screens.server + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ServerUrlTextFieldValueTest { + @Test + fun serverUrlTextFieldValue_placesCursorAtEndOfReplacementUrl() { + val url = "http://192.168.24.25:4096" + + val value = serverUrlTextFieldValue(url) + + assertEquals(url, value.text) + assertEquals(url.length, value.selection.start) + assertEquals(url.length, value.selection.end) + } + + @Test + fun serverUrlTextFieldValue_placesCursorAtEndOfShortReplacementUrl() { + val url = "http://pi.local:4096" + + val value = serverUrlTextFieldValue(url) + + assertEquals(url, value.text) + assertEquals(url.length, value.selection.start) + assertEquals(url.length, value.selection.end) + } +} diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/screens/sessions/SessionListViewModelTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/screens/sessions/SessionListViewModelTest.kt index 86bccc82..2cc1be45 100644 --- a/app/src/test/java/dev/blazelight/p4oc/ui/screens/sessions/SessionListViewModelTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/ui/screens/sessions/SessionListViewModelTest.kt @@ -1,5 +1,6 @@ package dev.blazelight.p4oc.ui.screens.sessions +import androidx.lifecycle.SavedStateHandle import dev.blazelight.p4oc.data.remote.mapper.MessageMapper import dev.blazelight.p4oc.data.session.SessionRepositoryImpl import dev.blazelight.p4oc.fakes.FakeWorkspaceClient @@ -154,6 +155,155 @@ class SessionListViewModelTest { repository.close() } + @Test + fun recreateWithSameSavedStateHandle_restoresGlobalSearchQueryAndExpandedSessions() = runTest(dispatcher) { + val client = FakeWorkspaceClient().apply { + sessionsByDirectoryAndSearch = mapOf( + Pair(null, "apple") to listOf(FakeWorkspaceClient.sessionDto("global", title = "apple global")), + ) + } + val repository = repository(client) + val savedStateHandle = SavedStateHandle() + val original = SessionListViewModel(repository, savedStateHandle) + advanceUntilIdle() + + original.updateSearchQuery("apple", directory = null) + original.toggleSessionExpanded("global") + advanceTimeBy(300) + advanceUntilIdle() + + val recreated = SessionListViewModel(repository, savedStateHandle) + advanceUntilIdle() + recreated.updateSearchDirectory(null) + advanceUntilIdle() + + assertEquals("apple", recreated.uiState.value.searchQuery) + assertNull(recreated.uiState.value.searchDirectory) + assertEquals(setOf("global"), recreated.uiState.value.expandedSessionIds) + assertEquals(listOf("global"), recreated.uiState.value.displayedSearchResults.map { it.session.id }) + assertEquals(SessionSearchStatus.Current, recreated.uiState.value.searchStatus) + repository.close() + } + + @Test + fun searchQueryAndExpandedSessions_areIsolatedByDirectoryContext() = runTest(dispatcher) { + val client = FakeWorkspaceClient().apply { + sessionsByDirectoryAndSearch = mapOf( + Pair("/project-a", "apple") to listOf( + FakeWorkspaceClient.sessionDto("a", title = "apple work", directory = "/project-a"), + ), + Pair("/project-b", "banana") to listOf( + FakeWorkspaceClient.sessionDto("b", title = "banana work", directory = "/project-b"), + ), + ) + } + val repository = repository(client) + val viewModel = SessionListViewModel(repository, SavedStateHandle()) + advanceUntilIdle() + + viewModel.updateSearchQuery("apple", directory = "/project-a") + viewModel.toggleSessionExpanded("a") + advanceTimeBy(300) + advanceUntilIdle() + assertEquals(listOf("a"), viewModel.uiState.value.displayedSearchResults.map { it.session.id }) + + viewModel.updateSearchDirectory("/project-b") + advanceUntilIdle() + assertEquals("", viewModel.uiState.value.searchQuery) + assertEquals("/project-b", viewModel.uiState.value.searchDirectory) + assertTrue(viewModel.uiState.value.expandedSessionIds.isEmpty()) + assertFalse(viewModel.uiState.value.isSearchActive) + + viewModel.updateSearchQuery("banana", directory = "/project-b") + viewModel.toggleSessionExpanded("b") + advanceTimeBy(300) + advanceUntilIdle() + assertEquals(listOf("b"), viewModel.uiState.value.displayedSearchResults.map { it.session.id }) + + viewModel.updateSearchDirectory("/project-a") + advanceUntilIdle() + + assertEquals("apple", viewModel.uiState.value.searchQuery) + assertEquals("/project-a", viewModel.uiState.value.searchDirectory) + assertEquals(setOf("a"), viewModel.uiState.value.expandedSessionIds) + assertEquals(listOf("a"), viewModel.uiState.value.displayedSearchResults.map { it.session.id }) + assertEquals(SessionSearchStatus.Current, viewModel.uiState.value.searchStatus) + repository.close() + } + + @Test + fun updateSearchQuery_blankClearsSearchOnlyForThatDirectoryContext() = runTest(dispatcher) { + val client = FakeWorkspaceClient().apply { + sessionsByDirectoryAndSearch = mapOf( + Pair("/project-a", "apple") to listOf( + FakeWorkspaceClient.sessionDto("a", title = "apple work", directory = "/project-a"), + ), + Pair("/project-b", "banana") to listOf( + FakeWorkspaceClient.sessionDto("b", title = "banana work", directory = "/project-b"), + ), + ) + } + val repository = repository(client) + val viewModel = SessionListViewModel(repository, SavedStateHandle()) + advanceUntilIdle() + + viewModel.updateSearchQuery("apple", directory = "/project-a") + advanceTimeBy(300) + advanceUntilIdle() + viewModel.updateSearchQuery("banana", directory = "/project-b") + advanceTimeBy(300) + advanceUntilIdle() + + viewModel.updateSearchQuery("", directory = "/project-b") + advanceUntilIdle() + + assertEquals("", viewModel.uiState.value.searchQuery) + assertEquals("/project-b", viewModel.uiState.value.searchDirectory) + assertFalse(viewModel.uiState.value.isSearchActive) + assertTrue(viewModel.uiState.value.searchResults.isEmpty()) + assertNull(viewModel.uiState.value.searchStatus) + + viewModel.updateSearchDirectory("/project-a") + advanceUntilIdle() + + assertEquals("apple", viewModel.uiState.value.searchQuery) + assertEquals(listOf("a"), viewModel.uiState.value.displayedSearchResults.map { it.session.id }) + assertEquals(SessionSearchStatus.Current, viewModel.uiState.value.searchStatus) + repository.close() + } + + @Test + fun restoredSearchWithNoMatches_keepsQueryAndCurrentNoResultsState() = runTest(dispatcher) { + val client = FakeWorkspaceClient().apply { + sessionsByDirectoryAndSearch = mapOf( + Pair("/project", "missing") to emptyList(), + ) + } + val repository = repository(client) + val savedStateHandle = SavedStateHandle() + val original = SessionListViewModel(repository, savedStateHandle) + advanceUntilIdle() + + original.updateSearchQuery("missing", directory = "/project") + advanceTimeBy(300) + advanceUntilIdle() + assertEquals(SessionSearchStatus.Current, original.uiState.value.searchStatus) + assertTrue(original.uiState.value.displayedSearchResults.isEmpty()) + + val recreated = SessionListViewModel(repository, savedStateHandle) + advanceUntilIdle() + recreated.updateSearchDirectory("/project") + advanceUntilIdle() + + assertEquals("missing", recreated.uiState.value.searchQuery) + assertEquals("/project", recreated.uiState.value.searchDirectory) + assertTrue(recreated.uiState.value.isSearchActive) + assertTrue(recreated.uiState.value.searchResults.isEmpty()) + assertTrue(recreated.uiState.value.displayedSearchResults.isEmpty()) + assertEquals("missing", recreated.uiState.value.serverSearchQuery) + assertEquals(SessionSearchStatus.Current, recreated.uiState.value.searchStatus) + repository.close() + } private fun repository(client: FakeWorkspaceClient): SessionRepositoryImpl = SessionRepositoryImpl( client = client, messageMapper = MessageMapper(Json { ignoreUnknownKeys = true }), diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/screens/settings/SkillsMetadataTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/screens/settings/SkillsMetadataTest.kt new file mode 100644 index 00000000..e914b663 --- /dev/null +++ b/app/src/test/java/dev/blazelight/p4oc/ui/screens/settings/SkillsMetadataTest.kt @@ -0,0 +1,47 @@ +package dev.blazelight.p4oc.ui.screens.settings + +import dev.blazelight.p4oc.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class SkillsMetadataTest { + + @Test + fun mcpStatusDescriptionRes_mapsKnownStatusesToStringResources() { + val expectedResourcesByStatus = mapOf( + MCP_STATUS_CONNECTED to R.string.skills_status_connected, + "disabled" to R.string.skills_status_disabled, + "failed" to R.string.skills_status_failed, + "needs_auth" to R.string.skills_status_needs_auth, + "needs_client_registration" to R.string.skills_status_needs_client_registration, + ) + + expectedResourcesByStatus.forEach { (status, expectedResource) -> + assertEquals(expectedResource, mcpStatusDescriptionRes(status)) + } + } + + @Test + fun mcpStatusDescriptionRes_mapsUnknownStatusToUnknownStringResource() { + assertEquals(R.string.skills_status_unknown, mcpStatusDescriptionRes("unexpected_status")) + } + + @Test + fun skillInfo_isEnabledOnlyForConnectedStatus() { + assertTrue(skillInfo(status = MCP_STATUS_CONNECTED).isEnabled) + assertFalse(skillInfo(status = "disabled").isEnabled) + } + + @Test + fun skillsErrorKind_exposesExpectedKindsForUiMapping() { + assertEquals(setOf(SkillsErrorKind.NotConnected, SkillsErrorKind.ApiError), SkillsErrorKind.entries.toSet()) + } + + private fun skillInfo(status: String) = SkillInfo( + name = "filesystem", + status = status, + source = "project", + ) +} diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/tabs/MainTabScreenPtyRequestTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/tabs/MainTabScreenPtyRequestTest.kt new file mode 100644 index 00000000..6c1eb556 --- /dev/null +++ b/app/src/test/java/dev/blazelight/p4oc/ui/tabs/MainTabScreenPtyRequestTest.kt @@ -0,0 +1,30 @@ +package dev.blazelight.p4oc.ui.tabs + +import dev.blazelight.p4oc.domain.server.WorkspaceKey +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class MainTabScreenPtyRequestTest { + @Test + fun `global workspace terminal request omits client guessed process context`() { + val request = createPtyRequestForWorkspace(WorkspaceKey.Global) + + assertNull(request.command) + assertEquals(emptyList(), request.args) + assertNull(request.cwd) + assertNull(request.title) + } + + @Test + fun `directory workspace terminal request uses directory cwd and basename title`() { + val request = createPtyRequestForWorkspace(WorkspaceKey.Directory("/repo/project")) + + assertEquals("/repo/project", request.cwd) + assertEquals("project", request.title) + assertEquals(emptyList(), request.args) + assertNull(request.command) + assertNotEquals(".", request.cwd) + } +} diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/tabs/TabBarTitleTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/tabs/TabBarTitleTest.kt new file mode 100644 index 00000000..434515ea --- /dev/null +++ b/app/src/test/java/dev/blazelight/p4oc/ui/tabs/TabBarTitleTest.kt @@ -0,0 +1,95 @@ +package dev.blazelight.p4oc.ui.tabs + +import dev.blazelight.p4oc.domain.server.WorkspaceKey +import dev.blazelight.p4oc.domain.session.SessionId +import org.junit.Assert.assertEquals +import org.junit.Test + +class TabBarTitleTest { + private val labels = TabTitleLabels( + fallbackTab = "Tab", + sessions = "Sessions", + chat = "Chat", + files = "Files", + file = "File", + terminal = "Terminal", + settings = "Settings", + projects = "Projects", + globalWorkspace = "Global", + sessionWorkspace = "Session", + ) + + @Test + fun `sessions route includes global workspace suffix`() { + assertEquals( + "Sessions · Global", + getTitleForRoute("sessions", labels, workspaceKey = WorkspaceKey.Global), + ) + } + + @Test + fun `filtered sessions route includes directory workspace suffix`() { + assertEquals( + "Sessions · project", + getTitleForRoute("sessions?project=1", labels, workspaceKey = WorkspaceKey.Directory("/repo/project")), + ) + } + + @Test + fun `chat route uses session title with directory workspace suffix`() { + assertEquals( + "Investigate bug · project", + getTitleForRoute( + route = "chat/session-1", + labels = labels, + sessionTitle = "Investigate bug", + workspaceKey = WorkspaceKey.Directory("/repo/project"), + ), + ) + } + + @Test + fun `chat route falls back to localized chat label`() { + assertEquals( + "Chat · Session", + getTitleForRoute( + route = "chat/session-1", + labels = labels, + workspaceKey = WorkspaceKey.SessionScoped(SessionId("session-1")), + ), + ) + } + + @Test + fun `files route in global workspace uses compact global label`() { + assertEquals( + "Global", + getTitleForRoute("files", labels, workspaceKey = WorkspaceKey.Global), + ) + } + + @Test + fun `files path route uses directory basename`() { + assertEquals( + "project", + getTitleForRoute("files/src/Main.kt", labels, workspaceKey = WorkspaceKey.Directory("/repo/project/")), + ) + } + + @Test + fun `settings and nested settings routes use settings label`() { + assertEquals("Settings", getTitleForRoute("settings", labels)) + assertEquals("Settings", getTitleForRoute("settings/about", labels)) + } + + @Test + fun `projects route uses projects label`() { + assertEquals("Projects", getTitleForRoute("projects", labels)) + } + + @Test + fun `unknown and null routes use fallback label`() { + assertEquals("Tab", getTitleForRoute("diff/1", labels)) + assertEquals("Tab", getTitleForRoute(null, labels)) + } +} From 0c08a61161503dc78740cbce49c39d9a829d8714 Mon Sep 17 00:00:00 2001 From: Jasmin Le Roux Date: Wed, 8 Jul 2026 15:41:58 +0200 Subject: [PATCH 11/22] Preserve no-port server URLs --- .tickets/oa-9pn0.md | 25 +++++ .../p4oc/core/network/ConnectionManager.kt | 15 +-- .../blazelight/p4oc/core/network/ServerUrl.kt | 20 ++-- .../network/ConnectionManagerFallbackTest.kt | 2 +- .../p4oc/core/network/ServerUrlTest.kt | 20 ++-- .../server/ServerViewModelIssue31Test.kt | 93 +++++++++++++++++++ 6 files changed, 144 insertions(+), 31 deletions(-) create mode 100644 .tickets/oa-9pn0.md create mode 100644 app/src/test/java/dev/blazelight/p4oc/ui/screens/server/ServerViewModelIssue31Test.kt diff --git a/.tickets/oa-9pn0.md b/.tickets/oa-9pn0.md new file mode 100644 index 00000000..7b2b020d --- /dev/null +++ b/.tickets/oa-9pn0.md @@ -0,0 +1,25 @@ +--- +id: oa-9pn0 +status: closed +deps: [] +links: [] +created: 2026-07-08T13:27:45Z +type: bug +priority: 1 +assignee: Jasmin Le Roux +external-ref: gh-31 +--- +# Preserve no-port server URL after login + +GitHub issue #31 reports that entering a server URL without an explicit port, such as https://my-host.example.com, succeeds initially but is persisted as https://my-host.example.com:4096. Reconnect then fails when the server is exposed through the scheme default port or reverse proxy rather than OpenCode port 4096. Investigation confirmed ServerUrl.normalizeConnectUrl defaults missing ports to DEFAULT_PORT=4096 and ServerViewModel saves that normalized URL via saveLastConnection and addRecentServer. + +## Acceptance Criteria + +When a user enters an http/https URL without an explicit port, the saved last connection and recent server URL preserve the no-port form. Connection attempts may still try the OpenCode default port internally where appropriate, but this must not overwrite the user-visible persisted reconnect URL. Explicit user ports remain preserved. Add/keep red tests covering ServerUrl no-port preservation and ServerViewModel.connectToRemote persistence. + + +## Notes + +**2026-07-08T13:39:41Z** + +Implemented fix: ServerUrl.normalizeConnectUrl now preserves absent user ports for saved/display reconnect URLs, while endpointKey keeps canonical :4096 identity. ConnectionManager.connectionCandidates now owns OpenCode default-port probing by trying :4096 first for no-port inputs and then the preserved no-port URL. Added ServerViewModelIssue31Test covering persisted last/recent URL and updated ServerUrl/ConnectionManager tests. Verification passed: JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew :app:compileDebugKotlin && ./gradlew :app:detekt && ./gradlew :app:testDebugUnitTest. diff --git a/app/src/main/java/dev/blazelight/p4oc/core/network/ConnectionManager.kt b/app/src/main/java/dev/blazelight/p4oc/core/network/ConnectionManager.kt index 5939a0c2..9b14f32b 100644 --- a/app/src/main/java/dev/blazelight/p4oc/core/network/ConnectionManager.kt +++ b/app/src/main/java/dev/blazelight/p4oc/core/network/ConnectionManager.kt @@ -209,18 +209,13 @@ class ConnectionManager constructor( config.copy(url = primaryUrl) } val parsed = primaryConfig.url.toHttpUrlOrNull() ?: return listOf(primaryConfig) - if (hasExplicitPort(config.url) || parsed.port != ServerUrl.DEFAULT_PORT) return listOf(primaryConfig) + if (hasExplicitPort(config.url)) return listOf(primaryConfig) - val fallbackPort = when (parsed.scheme) { - "http" -> 80 - "https" -> 443 - else -> return listOf(primaryConfig) - } - - val fallbackUrl = parsed.newBuilder().port(fallbackPort).build().toString().trimEnd('/') - if (fallbackUrl == primaryConfig.url.trimEnd('/')) return listOf(primaryConfig) + val opencodeUrl = parsed.newBuilder().port(ServerUrl.DEFAULT_PORT).build().toString().trimEnd('/') + val opencodeConfig = primaryConfig.copy(url = opencodeUrl) + if (opencodeUrl == primaryConfig.url.trimEnd('/')) return listOf(primaryConfig) - return listOf(primaryConfig, primaryConfig.copy(url = fallbackUrl)) + return listOf(opencodeConfig, primaryConfig) } internal fun hasExplicitPort(url: String): Boolean { diff --git a/app/src/main/java/dev/blazelight/p4oc/core/network/ServerUrl.kt b/app/src/main/java/dev/blazelight/p4oc/core/network/ServerUrl.kt index 010b6e0c..0b757e00 100644 --- a/app/src/main/java/dev/blazelight/p4oc/core/network/ServerUrl.kt +++ b/app/src/main/java/dev/blazelight/p4oc/core/network/ServerUrl.kt @@ -11,7 +11,7 @@ object ServerUrl { return buildUrl( scheme = components.scheme, host = components.formattedHost, - port = components.port, + port = components.explicitPort, path = components.path, ) } @@ -21,7 +21,7 @@ object ServerUrl { return buildUrl( scheme = components.scheme, host = components.formattedHost, - port = components.port, + port = components.canonicalPort, path = components.path, ) } @@ -29,7 +29,8 @@ object ServerUrl { private data class ParsedServerUrl( val scheme: String, val formattedHost: String, - val port: Int, + val explicitPort: Int?, + val canonicalPort: Int, val path: String, ) @@ -45,10 +46,8 @@ object ServerUrl { val host = parsed.host.substringBefore('%').lowercase() val formattedHost = if (':' in host) "[$host]" else host - val port = when { - hasExplicitPort(sanitizedCandidate) -> parsed.port - else -> DEFAULT_PORT - } + val explicitPort = parsed.port.takeIf { hasExplicitPort(sanitizedCandidate) } + val canonicalPort = explicitPort ?: DEFAULT_PORT val path = parsed.encodedPath .takeUnless { it == "/" } ?.trimEnd('/') @@ -57,7 +56,8 @@ object ServerUrl { return ParsedServerUrl( scheme = scheme, formattedHost = formattedHost, - port = port, + explicitPort = explicitPort, + canonicalPort = canonicalPort, path = path, ) } @@ -65,10 +65,10 @@ object ServerUrl { private fun buildUrl( scheme: String, host: String, - port: Int, + port: Int?, path: String, ): String { - val base = "$scheme://$host:$port" + val base = if (port == null) "$scheme://$host" else "$scheme://$host:$port" return if (path.isEmpty()) base else "$base$path" } diff --git a/app/src/test/java/dev/blazelight/p4oc/core/network/ConnectionManagerFallbackTest.kt b/app/src/test/java/dev/blazelight/p4oc/core/network/ConnectionManagerFallbackTest.kt index 0daf8944..ca03a9b8 100644 --- a/app/src/test/java/dev/blazelight/p4oc/core/network/ConnectionManagerFallbackTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/core/network/ConnectionManagerFallbackTest.kt @@ -50,7 +50,7 @@ class ConnectionManagerFallbackTest { } @Test - fun `implicit http opencode port can fall back to port 80`() { + fun `implicit http url probes opencode port before preserved no-port url`() { val primary = ServerConfig(url = "http://192.168.24.25") val candidates = manager.connectionCandidates(primary) diff --git a/app/src/test/java/dev/blazelight/p4oc/core/network/ServerUrlTest.kt b/app/src/test/java/dev/blazelight/p4oc/core/network/ServerUrlTest.kt index 8a13bdb7..2ea54e19 100644 --- a/app/src/test/java/dev/blazelight/p4oc/core/network/ServerUrlTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/core/network/ServerUrlTest.kt @@ -8,8 +8,8 @@ import org.junit.Test class ServerUrlTest { @Test - fun `bare host defaults to http and 4096`() { - assertEquals("http://example.com:4096", ServerUrl.normalizeConnectUrl("example.com")) + fun `bare host defaults to http without persisted port`() { + assertEquals("http://example.com", ServerUrl.normalizeConnectUrl("example.com")) } @Test @@ -18,19 +18,19 @@ class ServerUrlTest { } @Test - fun `explicit http without port uses opencode default port`() { - assertEquals("http://example.com:4096", ServerUrl.normalizeConnectUrl("http://example.com")) + fun `explicit http without port preserves absent port`() { + assertEquals("http://example.com", ServerUrl.normalizeConnectUrl("http://example.com")) } @Test - fun `explicit https without port uses opencode default port`() { - assertEquals("https://example.com:4096", ServerUrl.normalizeConnectUrl("https://example.com")) + fun `explicit https without port preserves absent port`() { + assertEquals("https://example.com", ServerUrl.normalizeConnectUrl("https://example.com")) } @Test fun `path is preserved for connect url`() { assertEquals( - "http://example.com:4096/foo/bar", + "http://example.com/foo/bar", ServerUrl.normalizeConnectUrl("example.com/foo/bar"), ) } @@ -38,14 +38,14 @@ class ServerUrlTest { @Test fun `query and fragment are stripped but path is preserved`() { assertEquals( - "http://example.com:4096/foo/bar", + "http://example.com/foo/bar", ServerUrl.normalizeConnectUrl("example.com/foo/bar?x=1#frag"), ) } @Test fun `host is lowercased`() { - assertEquals("https://example.com:4096", ServerUrl.normalizeConnectUrl("https://EXAMPLE.COM")) + assertEquals("https://example.com", ServerUrl.normalizeConnectUrl("https://EXAMPLE.COM")) } @Test @@ -61,7 +61,7 @@ class ServerUrlTest { @Test fun `ipv6 is bracketed and zone id is stripped with path preserved`() { assertEquals( - "http://[2001:db8::1]:4096/foo", + "http://[2001:db8::1]/foo", ServerUrl.normalizeConnectUrl("http://[2001:db8::1%wlan0]/foo"), ) } diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/screens/server/ServerViewModelIssue31Test.kt b/app/src/test/java/dev/blazelight/p4oc/ui/screens/server/ServerViewModelIssue31Test.kt new file mode 100644 index 00000000..8ad176d9 --- /dev/null +++ b/app/src/test/java/dev/blazelight/p4oc/ui/screens/server/ServerViewModelIssue31Test.kt @@ -0,0 +1,93 @@ +package dev.blazelight.p4oc.ui.screens.server + +import dev.blazelight.p4oc.core.datastore.RecentServer +import dev.blazelight.p4oc.core.datastore.SettingsDataStore +import dev.blazelight.p4oc.core.network.ConnectionManager +import dev.blazelight.p4oc.core.network.DiscoveryState +import dev.blazelight.p4oc.core.network.MdnsDiscoveryManager +import dev.blazelight.p4oc.core.network.ServerConfig +import dev.blazelight.p4oc.core.security.CredentialStore +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ServerViewModelIssue31Test { + private val dispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `connectToRemote persists no-port https url without appending opencode port`() = runTest(dispatcher) { + val settingsDataStore = mockk(relaxUnitFun = true) + val connectionManager = mockk() + val credentialStore = mockk() + val discoveryManager = mockk() + val savedConfig = slot() + val recentUrl = slot() + + every { settingsDataStore.recentServers } returns flowOf>(emptyList()) + coEvery { settingsDataStore.getLastConnection() } returns null + coEvery { settingsDataStore.saveLastConnection(capture(savedConfig), any()) } returns Unit + coEvery { + settingsDataStore.addRecentServer( + url = capture(recentUrl), + name = any(), + username = any(), + password = any(), + allowInsecure = any(), + ) + } returns Unit + coEvery { connectionManager.connect(any(), any()) } returns Result.success(emptyList()) + every { discoveryManager.discoveredServers } returns MutableStateFlow(emptyList()) + every { discoveryManager.discoveryState } returns MutableStateFlow(DiscoveryState.IDLE) + + val viewModel = ServerViewModel( + settingsDataStore = settingsDataStore, + connectionManager = connectionManager, + credentialStore = credentialStore, + mdnsDiscoveryManager = discoveryManager, + ) + advanceUntilIdle() + + viewModel.setRemoteUrl("https://my-host.example.com") + viewModel.connectToRemote() + advanceUntilIdle() + + coVerify { settingsDataStore.saveLastConnection(any(), any()) } + coVerify { + settingsDataStore.addRecentServer( + url = any(), + name = any(), + username = any(), + password = any(), + allowInsecure = any(), + ) + } + assertEquals("https://my-host.example.com", savedConfig.captured.url) + assertEquals("https://my-host.example.com", recentUrl.captured) + } +} From 835ca0c052b85414c2e062298867e6f8e05e3911 Mon Sep 17 00:00:00 2001 From: Jasmin Le Roux Date: Wed, 8 Jul 2026 17:41:54 +0200 Subject: [PATCH 12/22] Add pinned Home multi-server workspace flow --- .tickets/oa-07lr.md | 36 +++ .tickets/oa-0gah.md | 40 +++ .tickets/oa-1xnu.md | 31 +++ .tickets/oa-5sro.md | 27 ++ .tickets/oa-6swf.md | 41 +++ .tickets/oa-7ipn.md | 30 ++ .tickets/oa-97i6.md | 34 +++ .tickets/oa-cj0w.md | 33 +++ .tickets/oa-fac4.md | 45 +++ .tickets/oa-lnou.md | 36 +++ .tickets/oa-nugm.md | 38 +++ .tickets/oa-pjcl.md | 36 +++ .tickets/oa-ximf.md | 36 +++ .tickets/oa-xju6.md | 34 +++ .tickets/oa-yx4y.md | 32 +++ .tickets/oa-z8r2.md | 37 +++ app/detekt-baseline.xml | 73 ++++- .../p4oc/core/datastore/SettingsDataStore.kt | 202 +++++++++++++- .../core/network/ServerConnectionRegistry.kt | 89 ++++++ .../ui/attention/AttentionBadgeRegistry.kt | 53 ++++ .../blazelight/p4oc/ui/navigation/Screen.kt | 1 + .../p4oc/ui/screens/home/HomeScreen.kt | 261 ++++++++++++++++++ .../p4oc/ui/screens/home/HomeSummary.kt | 89 ++++++ .../p4oc/ui/screens/server/ServerScreen.kt | 85 ++++++ .../p4oc/ui/screens/server/ServerViewModel.kt | 25 ++ .../blazelight/p4oc/ui/tabs/MainTabScreen.kt | 251 ++++++++++------- .../p4oc/ui/tabs/StartWorkContext.kt | 53 ++++ .../dev/blazelight/p4oc/ui/tabs/TabBar.kt | 6 +- .../dev/blazelight/p4oc/ui/tabs/TabManager.kt | 134 ++++++--- .../dev/blazelight/p4oc/ui/tabs/TabNavHost.kt | 18 ++ .../dev/blazelight/p4oc/ui/tabs/TabState.kt | 24 ++ .../core/datastore/SavedServerRegistryTest.kt | 107 +++++++ .../network/ServerConnectionRegistryTest.kt | 146 ++++++++++ .../session/SessionRepositoryProviderTest.kt | 29 ++ .../attention/AttentionBadgeRegistryTest.kt | 41 +++ .../ui/screens/home/HomeSummaryBuilderTest.kt | 62 +++++ .../server/ServerViewModelIssue31Test.kt | 33 +++ .../p4oc/ui/tabs/StartWorkContextTest.kt | 58 ++++ .../p4oc/ui/tabs/TabManagerPersistenceTest.kt | 234 +++++++++++++++- 39 files changed, 2484 insertions(+), 156 deletions(-) create mode 100644 .tickets/oa-07lr.md create mode 100644 .tickets/oa-0gah.md create mode 100644 .tickets/oa-1xnu.md create mode 100644 .tickets/oa-5sro.md create mode 100644 .tickets/oa-6swf.md create mode 100644 .tickets/oa-7ipn.md create mode 100644 .tickets/oa-97i6.md create mode 100644 .tickets/oa-cj0w.md create mode 100644 .tickets/oa-fac4.md create mode 100644 .tickets/oa-lnou.md create mode 100644 .tickets/oa-nugm.md create mode 100644 .tickets/oa-pjcl.md create mode 100644 .tickets/oa-ximf.md create mode 100644 .tickets/oa-xju6.md create mode 100644 .tickets/oa-yx4y.md create mode 100644 .tickets/oa-z8r2.md create mode 100644 app/src/main/java/dev/blazelight/p4oc/core/network/ServerConnectionRegistry.kt create mode 100644 app/src/main/java/dev/blazelight/p4oc/ui/attention/AttentionBadgeRegistry.kt create mode 100644 app/src/main/java/dev/blazelight/p4oc/ui/screens/home/HomeScreen.kt create mode 100644 app/src/main/java/dev/blazelight/p4oc/ui/screens/home/HomeSummary.kt create mode 100644 app/src/main/java/dev/blazelight/p4oc/ui/tabs/StartWorkContext.kt create mode 100644 app/src/test/java/dev/blazelight/p4oc/core/datastore/SavedServerRegistryTest.kt create mode 100644 app/src/test/java/dev/blazelight/p4oc/core/network/ServerConnectionRegistryTest.kt create mode 100644 app/src/test/java/dev/blazelight/p4oc/ui/attention/AttentionBadgeRegistryTest.kt create mode 100644 app/src/test/java/dev/blazelight/p4oc/ui/screens/home/HomeSummaryBuilderTest.kt create mode 100644 app/src/test/java/dev/blazelight/p4oc/ui/tabs/StartWorkContextTest.kt diff --git a/.tickets/oa-07lr.md b/.tickets/oa-07lr.md new file mode 100644 index 00000000..783227da --- /dev/null +++ b/.tickets/oa-07lr.md @@ -0,0 +1,36 @@ +--- +id: oa-07lr +status: closed +deps: [oa-6swf, oa-97i6, oa-nugm] +links: [] +created: 2026-07-08T14:42:17Z +type: feature +priority: 1 +assignee: Jasmin Le Roux +parent: oa-xju6 +--- +# Implement Home workspace detail drill-in + +Clicking a workspace card in Home opens a workspace detail view inside Home rather than immediately creating a tab. + +Workspace detail is the bridge between existing sessions and contextual Files/Terminal/Chat actions. + +## Design + +Workspace detail content: +- Workspace identity: server badge/name and full directory. +- Open work in this workspace: existing chat/files/terminal tabs with focus actions. +- Filtered sessions in this workspace using current SessionList behavior where possible. +- Recent workspace activity if available (recent files, terminal status), but do not create a notification feed. +- Small Start new here row: + Chat, + Files, + Terminal, Pin. + +Back behavior: workspace detail -> Home top-level. + +## Acceptance Criteria + +- Workspace card click drills into workspace detail without creating a new tab. +- Existing chat/files/terminal tabs for that workspace are detected and focusable. +- Filtered sessions list supports search/open and retains necessary session actions (rename/share/delete/summarize/view changes) or links to a full filtered Sessions view. +- Start new here actions delegate to Start Work coordinator with target prefilled. +- Tests cover workspace click no-tab-creation, focus existing tab, and start new here target prefill. + diff --git a/.tickets/oa-0gah.md b/.tickets/oa-0gah.md new file mode 100644 index 00000000..63640a9c --- /dev/null +++ b/.tickets/oa-0gah.md @@ -0,0 +1,40 @@ +--- +id: oa-0gah +status: closed +deps: [oa-6swf, oa-7ipn, oa-ximf] +links: [] +created: 2026-07-08T14:42:17Z +type: feature +priority: 1 +assignee: Jasmin Le Roux +parent: oa-xju6 +--- +# Introduce multi-server connection registry + +Replace/extend single active ConnectionManager assumptions with a registry capable of managing multiple server connections independently. + +Flattened mixed-server tabs require Alpha chat, Beta files, and Local terminal to coexist without changing a global current server under the user's feet. + +## Design + +Candidate API: +ServerConnectionRegistry.connection(serverRef): StateFlow +ServerConnectionRegistry.api(serverRef): OpenCodeApi? +ServerConnectionRegistry.connect(serverId) +ServerConnectionRegistry.disconnect(serverId) +ServerConnectionRegistry.reconnectAll() + +Each server owns its own OkHttp/auth client, API, SSE/event source, generation, reconnect policy, credential state, and coroutine scope. + +Lifecycle policy: +- Servers with open tabs are kept/reconnected on foreground. +- Servers with no open tabs can lazy-connect when selected/opened. +- Auth failures stop retry storms and show badge/state. + +## Acceptance Criteria + +- Two saved servers can have independent connection states simultaneously. +- Failure/auth issue on one server does not block or overwrite another server's state. +- Existing single-server flows still work. +- Tests cover independent connect/disconnect/error states and foreground reconnect policy. + diff --git a/.tickets/oa-1xnu.md b/.tickets/oa-1xnu.md new file mode 100644 index 00000000..6736c09b --- /dev/null +++ b/.tickets/oa-1xnu.md @@ -0,0 +1,31 @@ +--- +id: oa-1xnu +status: closed +deps: [oa-6swf, oa-fac4, oa-lnou] +links: [] +created: 2026-07-08T14:42:17Z +type: bug +priority: 2 +assignee: Jasmin Le Roux +parent: oa-xju6 +--- +# Wire Files and Terminal creation/focus to workspace identity + +Files and Terminal actions from Home workspace detail and Start Work must focus existing tabs or create new tabs using explicit server/workspace identity. + +## Design + +Suggested default policies: +- Files: one Files tab per server/workspace by default; focus existing if present. +- Terminal: allow multiple PTYs, but show/focus recent terminal when selecting existing terminal; + Terminal creates a new PTY in target cwd. +- Chat: one Chat tab per session; New chat creates session in target workspace. + +TabManager should expose find/focus helpers by server/workspace/route type. + +## Acceptance Criteria + +- Home workspace detail can focus existing Files tab for that workspace. +- + Files creates/focuses Files tab using explicit server/workspace. +- + Terminal creates PTY against the target server/workspace cwd, not global/default cwd. +- Tests cover duplicate prevention/focus behavior and explicit PTY cwd/server target. + diff --git a/.tickets/oa-5sro.md b/.tickets/oa-5sro.md new file mode 100644 index 00000000..ae66a361 --- /dev/null +++ b/.tickets/oa-5sro.md @@ -0,0 +1,27 @@ +--- +id: oa-5sro +status: open +deps: [oa-6swf, oa-yx4y, oa-07lr, oa-fac4, oa-nugm, oa-pjcl, oa-1xnu, oa-cj0w] +links: [] +created: 2026-07-08T14:42:17Z +type: task +priority: 1 +assignee: Jasmin Le Roux +parent: oa-xju6 +--- +# Approval gate: staged rollout and visual QA + +Before shipping, perform staged verification and visual QA for the pinned Home + Start Work architecture. + +## Acceptance Criteria + +- Compile, detekt, and relevant unit/UI tests pass. +- Manual/visual QA covers: first connect, restore existing chat, Home server carousel, workspace drill-in, + from active chat, + from Home workspace detail, browse sessions, server auth failure, server removal with open tabs, app background/reconnect. +- Screenshots demonstrate Home = existing work and + = new work distinction. +- Approval recorded before release branch merge. + + +## Verification Notes + +- 2026-07-08: `JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew :app:compileDebugKotlin && JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew :app:detekt && JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew :app:testDebugUnitTest` passed. +- 2026-07-08: Manual/visual QA is blocked because `adb devices` returned no connected emulator/device. Do not close until screenshots cover the required 11 scenarios. diff --git a/.tickets/oa-6swf.md b/.tickets/oa-6swf.md new file mode 100644 index 00000000..086898d2 --- /dev/null +++ b/.tickets/oa-6swf.md @@ -0,0 +1,41 @@ +--- +id: oa-6swf +status: closed +deps: [] +links: [] +created: 2026-07-08T14:42:17Z +type: task +priority: 1 +assignee: Jasmin Le Roux +parent: oa-xju6 +--- +# Approval gate: pinned Home plus Start Work UX spec + +Produce and get explicit approval for the detailed UX contract before implementation. + +Spec must be grounded in actual app flows, not generic dashboard visuals: +- Home opens existing/resumable work. +- + creates/opens new work from current context. +- Workspace click opens Home workspace detail rather than immediately creating a tab. +- Sessions list remains available as filtered history/browse inside Home/workspace detail. +- Notifications remain badges/dots only. + +## Design + +Use these local prototypes as design evidence/input: +- local-adb-screenshots/home-workspace-detail-plus.html +- local-adb-screenshots/pinned-home-app-structure-variants.html +- local-adb-screenshots/pinned-home-ux-variants.html + +Preferred direction from exploration: +- Top-level Home: server carousel/status filters, recent workspace cards, sessions list. +- Workspace detail: workspace identity, open work in this workspace, filtered sessions, small Start new here row. +- + sheet: target prefilled from active tab or selected Home workspace, actions New chat / Files tab / Terminal / Choose another target. + +## Acceptance Criteria + +- Approved UX doc describes exact Home top-level sections, workspace detail sections, and + sheet behavior. +- Back behavior is specified: Home top -> workspace detail -> filtered sessions returns within Home, not tab-close behavior. +- Empty/offline/auth-required states are specified. +- Decision is recorded whether + stays separate because it is context-fast, or is merged into Home if not. + diff --git a/.tickets/oa-7ipn.md b/.tickets/oa-7ipn.md new file mode 100644 index 00000000..c1d92436 --- /dev/null +++ b/.tickets/oa-7ipn.md @@ -0,0 +1,30 @@ +--- +id: oa-7ipn +status: closed +deps: [] +links: [] +created: 2026-07-08T14:42:17Z +type: task +priority: 1 +assignee: Jasmin Le Roux +parent: oa-xju6 +--- +# Approval gate: multi-server architecture plan + +Produce and get explicit approval for an architecture plan before touching core connection/repository lifecycle code. + +Plan must cover: +- Server registry/config persistence. +- Multi-server connection registry versus current single ConnectionManager assumptions. +- Tab identity and persistence including server/workspace/route. +- Repository ownership/lifecycle keyed by server + workspace + generation. +- Home aggregation without eager-loading every session on every server. +- Start Work coordinator/context. + +## Acceptance Criteria + +- Architecture plan states invariants and forbidden patterns: no global/default current server for work actions; every tab/action has explicit server/workspace target. +- Plan includes migration path from current single-server behavior. +- Plan identifies which existing classes change: SettingsDataStore/server config, ConnectionManager, TabManager/TabState, MainTabScreen, SessionRepositoryProvider/WorkspaceRepositoryOwner, SessionListViewModel, Files/Terminal creation flows. +- Plan is approved before implementation tickets that depend on it begin. + diff --git a/.tickets/oa-97i6.md b/.tickets/oa-97i6.md new file mode 100644 index 00000000..1d91827d --- /dev/null +++ b/.tickets/oa-97i6.md @@ -0,0 +1,34 @@ +--- +id: oa-97i6 +status: closed +deps: [oa-6swf, oa-z8r2, oa-ximf] +links: [] +created: 2026-07-08T14:42:17Z +type: feature +priority: 1 +assignee: Jasmin Le Roux +parent: oa-xju6 +--- +# Add pinned non-closeable Home surface + +Add pinned Home as the leftmost tab/surface. Home replaces the concept of creating New Sessions tabs and becomes the persistent browse/resume surface. + +## Design + +Home top-level content, per approved UX: +- Horizontal server carousel/status filters. +- Recent/pinned workspace cards tagged with server badges. +- Existing/open work summary where useful. +- Sessions list section preserving current Sessions capabilities or linking into filtered Sessions. +- Server management entry point, but not full server config UI. + +Notifications/attention remain badges/dots on Home/tab/server/workspace indicators, not feed cards. + +## Acceptance Criteria + +- Home appears pinned left, non-closeable, and survives tab close/reorder flows. +- Existing tabs remain next to Home and behave normally. +- Home does not load or display notification feed content; badges only. +- New Sessions tab option is removed, hidden, or superseded by Home/Browse Sessions according to approved UX. +- UI tests cover Home pinned/non-closeable behavior and tab focus behavior. + diff --git a/.tickets/oa-cj0w.md b/.tickets/oa-cj0w.md new file mode 100644 index 00000000..c3884581 --- /dev/null +++ b/.tickets/oa-cj0w.md @@ -0,0 +1,33 @@ +--- +id: oa-cj0w +status: closed +deps: [oa-6swf, oa-7ipn, oa-z8r2, oa-0gah] +links: [] +created: 2026-07-08T14:42:17Z +type: bug +priority: 1 +assignee: Jasmin Le Roux +parent: oa-xju6 +--- +# Restore mixed-server tabs and Home state safely + +Persist and restore mixed-server tabs, pinned Home, active tab, and selected Home workspace detail safely across process death/app restart. + +## Design + +Restore order: +1. Load server registry. +2. Restore pinned Home and normal tabs. +3. Resolve server refs for every normal tab. +4. Mark tabs offline/orphaned when server missing/unavailable. +5. Start reconnect for servers used by open tabs according to policy. +6. Home renders restored tabs/summaries immediately with stale/offline labels where needed. + +## Acceptance Criteria + +- Pinned Home is restored and cannot be duplicated/closed accidentally. +- Mixed-server tabs restore with correct server/workspace/route labels. +- Missing server config produces a clear orphan/offline tab state, not fallback to another server. +- Selected Home workspace detail restoration is specified and tested or intentionally reset to top-level Home. +- Tests cover app restart with Alpha chat + Beta files + Local terminal. + diff --git a/.tickets/oa-fac4.md b/.tickets/oa-fac4.md new file mode 100644 index 00000000..8d7aa3cb --- /dev/null +++ b/.tickets/oa-fac4.md @@ -0,0 +1,45 @@ +--- +id: oa-fac4 +status: closed +deps: [oa-6swf, oa-97i6, oa-z8r2] +links: [] +created: 2026-07-08T14:42:17Z +type: feature +priority: 1 +assignee: Jasmin Le Roux +parent: oa-xju6 +--- +# Replace plus dropdown with context-fast Start Work sheet + +Replace the current tabbar + dropdown (New Sessions tab / New Files tab / New Terminal tab) with a context-fast Start Work sheet. + +Design rule: +- Home = open existing/resume/browse. +- + = create/open new from the current context. + +## Design + +StartWorkContext should include: +- source: active tab, Home workspace detail, Home top-level, etc. +- default server/workspace from current tab or selected workspace. +- default action if invoked from an inline + Chat/+Files/+Terminal button. + +Actions: +- New chat +- Files tab +- Terminal +- Browse sessions / choose existing +- Choose another target + +If invoked on Home top-level with no selected workspace, show target picker first. +If invoked in workspace detail or work tab, default target is prefilled. + +## Acceptance Criteria + +- + no longer offers New Sessions tab as a primary action. +- From Chat/Files/Terminal, + defaults to that tab's server/workspace. +- From Home workspace detail, + defaults to selected workspace. +- User can choose another server/workspace target. +- Creating Files/Terminal/Chat uses explicit server/workspace and never falls back to hidden global current server. +- Tests cover default target derivation and create/focus behavior for each action. + diff --git a/.tickets/oa-lnou.md b/.tickets/oa-lnou.md new file mode 100644 index 00000000..4156913a --- /dev/null +++ b/.tickets/oa-lnou.md @@ -0,0 +1,36 @@ +--- +id: oa-lnou +status: closed +deps: [oa-6swf, oa-7ipn, oa-0gah, oa-z8r2] +links: [] +created: 2026-07-08T14:42:17Z +type: bug +priority: 1 +assignee: Jasmin Le Roux +parent: oa-xju6 +--- +# Scope repositories and data owners by server and workspace + +Repository/data-owner lifecycle must be keyed by server + workspace + generation, not just directory or active tab state. + +Home aggregates across servers/workspaces, while Chat/Files/Terminal tabs consume scoped repositories. Duplicate paths on different servers must not share state. + +## Design + +Update SessionRepositoryProvider/WorkspaceRepositoryOwner patterns so ownership key includes: +- ServerRef / server id / endpoint key +- WorkspaceKey/directory/session/global +- ServerGeneration where relevant + +Avoid forbidden patterns: +- Pulling active tab workspace from unrelated UI state inside repository constructors. +- Mutable global workspace variables. +- Fallback directory chains. + +## Acceptance Criteria + +- Same directory string on two servers creates distinct repository owners and data streams. +- Closing one tab does not close a repository still used by another tab/home detail for same server/workspace. +- Server reconnect/generation changes recreate only affected server/workspace owners. +- Tests cover duplicate directory on two servers and shared owner ref-count/lifecycle behavior. + diff --git a/.tickets/oa-nugm.md b/.tickets/oa-nugm.md new file mode 100644 index 00000000..c9ab7c6f --- /dev/null +++ b/.tickets/oa-nugm.md @@ -0,0 +1,38 @@ +--- +id: oa-nugm +status: closed +deps: [oa-6swf, oa-7ipn, oa-lnou] +links: [] +created: 2026-07-08T14:42:17Z +type: feature +priority: 2 +assignee: Jasmin Le Roux +parent: oa-xju6 +--- +# Create bounded Home session/workspace summary loading + +Home needs summaries across servers/workspaces without eagerly loading every session history or subscribing to every repository forever. + +## Design + +Summary model should be lightweight: +- server id/badge +- workspace id/path/display +- session id/title/updated/status/presence +- open tab indicator +- counts for sessions/tabs where cheap + +Loading policy: +- open tabs first +- pinned/recent workspaces next +- last N sessions per connected server/workspace +- lazy load full workspace detail on click +- broad search can call server-side/session APIs with explicit scope + +## Acceptance Criteria + +- Home top-level renders useful summaries without loading full chat message histories. +- One slow/offline server does not block Home summaries from other servers. +- Summary loading is cancellable/lifecycle-aware. +- Tests cover bounded loading and partial failure behavior. + diff --git a/.tickets/oa-pjcl.md b/.tickets/oa-pjcl.md new file mode 100644 index 00000000..f289c907 --- /dev/null +++ b/.tickets/oa-pjcl.md @@ -0,0 +1,36 @@ +--- +id: oa-pjcl +status: closed +deps: [oa-6swf, oa-97i6, oa-0gah] +links: [] +created: 2026-07-08T14:42:17Z +type: feature +priority: 2 +assignee: Jasmin Le Roux +parent: oa-xju6 +--- +# Add cross-server attention badge registry + +Implement compact attention/badge state for Home, tabs, servers, and workspaces. Do not add notification content to Home. + +## Design + +Signals may include: +- unread chat response/completion +- waiting for user input/permission/question +- server auth/reconnect/error +- terminal finished/error + +Presentation: +- Home badge for aggregate attention. +- Tab badges for tab-local attention. +- Server/workspace badges/dots for scoped attention. +- No notification feed/cards in Home. + +## Acceptance Criteria + +- Attention state is scoped by server/workspace/tab and does not leak across servers. +- Home shows aggregate badge/dot only, not notification feed content. +- Clearing/focusing tab updates relevant badge state. +- Tests cover badge aggregation and per-server isolation. + diff --git a/.tickets/oa-ximf.md b/.tickets/oa-ximf.md new file mode 100644 index 00000000..ef0c1306 --- /dev/null +++ b/.tickets/oa-ximf.md @@ -0,0 +1,36 @@ +--- +id: oa-ximf +status: closed +deps: [oa-6swf, oa-7ipn] +links: [] +created: 2026-07-08T14:42:17Z +type: feature +priority: 1 +assignee: Jasmin Le Roux +parent: oa-xju6 +--- +# Add durable server registry and management model + +Create a durable saved-server registry distinct from last connection/recent servers. This supports Home server carousel/status, Start Work target selection, and server management. + +Current state has last connection and recent server concepts. Multi-server Home needs saved server inventory with stable identity, display URL, canonical endpoint key, credentials linkage, friendly name, connection policy, and default/pinned metadata. + +## Design + +Candidate data model: +SavedServer(id, endpoint, endpointKey, displayName, username, allowInsecure, lastConnectedAt, pinned, defaultWorkspace) + +Preserve the issue #31 invariant: +- display/persisted URL preserves user-entered no-port URL. +- endpointKey is canonical identity. +- connection candidates are internal probing details. + +Credential material stays outside report/UI and remains in CredentialStore or equivalent secure storage keyed by server identity. + +## Acceptance Criteria + +- Saved servers can be added, edited, removed, listed, and looked up by stable id/endpoint key. +- Existing last-connection/recent-server data migrates or is surfaced without data loss. +- No API keys/passwords are stored in DataStore JSON/plain preferences. +- Unit tests cover no-port URL preservation, endpoint dedupe, edit/remove behavior, and migration from existing recent/last connection state. + diff --git a/.tickets/oa-xju6.md b/.tickets/oa-xju6.md new file mode 100644 index 00000000..bbd6d730 --- /dev/null +++ b/.tickets/oa-xju6.md @@ -0,0 +1,34 @@ +--- +id: oa-xju6 +status: open +deps: [] +links: [] +created: 2026-07-08T14:42:17Z +type: epic +priority: 1 +assignee: Jasmin Le Roux +--- +# Pinned Home and multi-server Start Work architecture + +Implement the product/architecture direction for P4OC where a non-closeable pinned Home surface opens/resumes existing work, the + affordance creates/opens new work from current context, tabs can span multiple servers/workspaces, and server configuration/lifecycle is managed separately. + +Current app reality to preserve: +- Users connect through ServerScreen and currently navigate into Sessions/Chat-oriented work. +- MainTabScreen restores persisted tabs and creates a Sessions tab when none are restored. +- Current + menu creates New Sessions tab, New Files tab, or New Terminal tab. +- Sessions screen owns search, quick-create, project grouping, expand/collapse, session actions (rename/delete/share/summarize/view changes), and session click into Chat. +- Chat/Files/Terminal tabs are contextual and should keep explicit workspace identity. + +Target mental model: +- Home = pinned, non-closeable surface for existing/resumable work: connected servers, recent workspaces, workspace detail, filtered sessions, existing tabs. +- + = context-fast creation/opening of new work: new chat, files tab, terminal, or choose another target. +- Servers = configuration/credential/lifecycle management, not a mode users switch through to work. +- Notifications/attention remain badges/dots on Home/tabs/server/workspace indicators, not a notification feed. + +## Acceptance Criteria + +- All child tickets are either closed or explicitly superseded. +- Fresh-session implementer can follow ticket dependency order without hidden conversation context. +- Final implementation preserves existing Chat/Files/Terminal/Sessions functionality while replacing the weird New Sessions tab flow with pinned Home + Start Work. +- Mixed server/workspace identity is explicit for every tab/action; no hidden global current-server fallback is introduced. + diff --git a/.tickets/oa-yx4y.md b/.tickets/oa-yx4y.md new file mode 100644 index 00000000..33afe90d --- /dev/null +++ b/.tickets/oa-yx4y.md @@ -0,0 +1,32 @@ +--- +id: oa-yx4y +status: closed +deps: [oa-6swf, oa-ximf] +links: [] +created: 2026-07-08T14:42:17Z +type: feature +priority: 2 +assignee: Jasmin Le Roux +parent: oa-xju6 +--- +# Build Servers management screen + +Build the dedicated Servers/Connections management surface. This is not the work hierarchy; it manages connection config, credentials, discovery, reconnect, and deletion. + +## Design + +Surface should include: +- Saved server list with status badges/dots. +- Add/edit server URL/display name/username/allow insecure/default workspace. +- Login/update credentials. +- Reconnect/disconnect actions. +- mDNS/discovered server section where appropriate. +- Remove server with warnings when open tabs/recent workspaces reference it. + +## Acceptance Criteria + +- User can manage server configs without entering Home work hierarchy. +- Removing a server with open tabs requires confirmation and states what happens to affected tabs. +- Auth-required/offline/reconnecting states are visible as status/badges. +- Tests or UI smoke notes cover add/edit/remove/reconnect and remove-with-open-tabs warning. + diff --git a/.tickets/oa-z8r2.md b/.tickets/oa-z8r2.md new file mode 100644 index 00000000..289f790a --- /dev/null +++ b/.tickets/oa-z8r2.md @@ -0,0 +1,37 @@ +--- +id: oa-z8r2 +status: closed +deps: [oa-6swf, oa-7ipn, oa-ximf] +links: [] +created: 2026-07-08T14:42:17Z +type: bug +priority: 1 +assignee: Jasmin Le Roux +parent: oa-xju6 +--- +# Make tab identity explicitly server scoped + +Ensure every normal tab carries explicit server + workspace + route identity. Home is the only global/non-workspace pinned surface. + +Current workspace cutover already requires explicit workspace identity. Multi-server Home extends this: workspace identity must include server identity everywhere tabs/actions are persisted or restored. + +## Design + +Audit and update: +- TabInstance/TabState persisted model. +- TabManager.createTab inputs. +- Tab restore/save path in MainTabScreen/SettingsDataStore. +- Tab title/icon labels to include compact server/workspace context where needed. +- Existing Screen.Sessions/Files/Terminal routes. + +Home tab: +- pinned, non-closeable, global aggregator route. +- not treated as a normal Sessions tab. + +## Acceptance Criteria + +- Persisted tabs include enough server identity to restore mixed-server tabs. +- Restoring a tab whose server is missing/unavailable yields a clear offline/orphan state, not wrong-server fallback. +- No work tab is created with null/default server context unless explicitly global and justified. +- Tests cover save/restore mixed-server tabs and missing-server restore. + diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml index 1edf1d21..488bf615 100644 --- a/app/detekt-baseline.xml +++ b/app/detekt-baseline.xml @@ -2,7 +2,16 @@ + ArgumentListWrapping:AttentionBadgeRegistryTest.kt$AttentionBadgeRegistryTest$(AttentionKey(alpha, workspace, "tab-a"), AttentionSeverity.Warning, "awaiting input") + ArgumentListWrapping:AttentionBadgeRegistryTest.kt$AttentionBadgeRegistryTest$(AttentionSignal(AttentionKey(alpha, workspace, "tab-a"), AttentionSeverity.Warning, "awaiting input")) + ArgumentListWrapping:AttentionBadgeRegistryTest.kt$AttentionBadgeRegistryTest$(alpha, workspace, "tab-a") ArgumentListWrapping:FileExplorerScreen.kt$(Icons.AutoMirrored.Filled.NoteAdd, contentDescription = null, tint = theme.textMuted) + ArgumentListWrapping:ServerConnectionRegistry.kt$ServerConnectionRegistry$(serverRef.endpointKey) + ArgumentListWrapping:TabManagerPersistenceTest.kt$TabManagerPersistenceTest$(alpha.endpointKey, beta.endpointKey, local.endpointKey) + ArgumentListWrapping:TabManagerPersistenceTest.kt$TabManagerPersistenceTest$(first.serverEndpointKey, second.serverEndpointKey) + ArgumentListWrapping:TabManagerPersistenceTest.kt$TabManagerPersistenceTest$(listOf(alpha.endpointKey, beta.endpointKey, local.endpointKey), restoredWorkTabs.map { it.serverEndpointKey }) + ArgumentListWrapping:TabManagerPersistenceTest.kt$TabManagerPersistenceTest$(listOf(server.endpointKey, otherServer.endpointKey), listOf(first.serverEndpointKey, second.serverEndpointKey)) + ArgumentListWrapping:TabManagerPersistenceTest.kt$TabManagerPersistenceTest$(server.endpointKey, otherServer.endpointKey) ArgumentListWrapping:TabNavHost.kt$(backStackEntry, navController, workspaceRoute, workspaceOwner, backStackEntry.destination.route) ArgumentListWrapping:ToolGroupWidget.kt$(pendingPermissionIdsByCallId[tool.callID] ?: tool.callID) CommentWrapping:SoraCodeEditorView.kt$/* autoComplete = */ @@ -32,7 +41,8 @@ CyclomaticComplexMethod:StreamingMarkdown.kt$internal fun parseMarkdownBlocks(text: String): List<MarkdownBlock> CyclomaticComplexMethod:StreamingMarkdown.kt$private fun inlineMarkdown(text: String, colors: MarkdownRenderColors): AnnotatedString CyclomaticComplexMethod:TabBar.kt$fun getTitleForRoute( route: String?, labels: TabTitleLabels, sessionTitle: String? = null, workspaceKey: WorkspaceKey? = null, ): String - CyclomaticComplexMethod:TabNavHost.kt$@Composable fun TabNavHost( navController: NavHostController, tabManager: TabManager, tabId: String, onDisconnect: () -> Unit, onNewFilesTab: () -> Unit = {}, onNewTerminalTab: () -> Unit = {}, onCloseTab: () -> Unit = {}, isActiveTab: Boolean = true, startRoute: String = Screen.Sessions.route, workspaceOwner: WorkspaceRepositoryOwner, onConnectionStateChanged: ((SessionConnectionState?) -> Unit)? = null, modifier: androidx.compose.ui.Modifier = androidx.compose.ui.Modifier ) + CyclomaticComplexMethod:TabManager.kt$TabManager$fun restoreState( state: PersistedTabState, availableServers: Map<String, ServerRef>, fallbackServer: ServerRef? = null, ): RestoreResult + CyclomaticComplexMethod:TabNavHost.kt$@Composable fun TabNavHost( navController: NavHostController, tabManager: TabManager, tabId: String, onDisconnect: () -> Unit, onNewFilesTab: () -> Unit = {}, onNewTerminalTab: () -> Unit = {}, onCloseTab: () -> Unit = {}, isActiveTab: Boolean = true, startRoute: String = Screen.Sessions.route, workspaceOwner: WorkspaceRepositoryOwner, serverRef: ServerRef, onConnectionStateChanged: ((SessionConnectionState?) -> Unit)? = null, modifier: androidx.compose.ui.Modifier = androidx.compose.ui.Modifier ) CyclomaticComplexMethod:TermuxTerminalView.kt$KeyInterceptingContainer$private fun handleKeyDown(event: KeyEvent): Boolean CyclomaticComplexMethod:ToolCallWidget.kt$private fun getToolCompactDescription(tool: Part.Tool): String CyclomaticComplexMethod:ToolComponents.kt$@Composable fun DiffPreview( diffContent: String, modifier: Modifier = Modifier ) @@ -41,6 +51,7 @@ CyclomaticComplexMethod:ToolComponents.kt$fun getToolDescription(toolName: String, input: JsonObject): String CyclomaticComplexMethod:ToolGroupWidget.kt$@Composable fun ToolGroupWidget( tools: List<Part.Tool>, defaultState: ToolWidgetState, pendingPermissionIdsByCallId: Map<String, String> = emptyMap(), onToolApprove: (String) -> Unit, onToolDeny: (String) -> Unit, onOpenSubSession: ((String) -> Unit)? = null, modifier: Modifier = Modifier ) EmptyFunctionBlock:InsecureTls.kt$<no name provided>${} + EmptyFunctionBlock:TabManagerPersistenceTest.kt${} Filename:ComponentPreviews.kt$dev.blazelight.p4oc.ui.preview.ComponentPreviews.kt Filename:TodoDtos.kt$dev.blazelight.p4oc.data.remote.dto.TodoDtos.kt FunctionNaming:AgentsConfigScreen.kt$@Composable private fun AgentDetailDialog( agent: AgentInfo, onDismiss: () -> Unit ) @@ -105,6 +116,10 @@ FunctionNaming:FileViewerScreen.kt$@Composable private fun DiscardChangesDialog( onConfirm: () -> Unit, onDismiss: () -> Unit, ) FunctionNaming:FileViewerScreen.kt$@Composable private fun SaveDiffDialog( preview: SavePreview, isSaving: Boolean, onConfirm: () -> Unit, onDismiss: () -> Unit, ) FunctionNaming:FileViewerScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun FileViewerScreen( path: String, viewModel: FilesViewModel, onNavigateBack: () -> Unit ) + FunctionNaming:HomeScreen.kt$@Composable fun HomeScreen( summary: HomeSummaryState, onBrowseSessions: () -> Unit, onOpenFiles: () -> Unit, onOpenTerminal: () -> Unit, modifier: Modifier = Modifier, onWorkspaceSelected: (WorkspaceSummary) -> Unit = {}, ) + FunctionNaming:HomeScreen.kt$@Composable private fun HomeActionRow( label: String, description: String, icon: @Composable () -> Unit, onClick: () -> Unit, testTag: String, modifier: Modifier = Modifier, ) + FunctionNaming:HomeScreen.kt$@Composable private fun HomeSection(title: String, body: String) + FunctionNaming:HomeScreen.kt$@Composable private fun WorkspaceDetail( workspace: WorkspaceSummary, openWork: List<OpenWorkSummary>, onBack: () -> Unit, onOpenFiles: () -> Unit, onOpenTerminal: () -> Unit, modifier: Modifier = Modifier, ) FunctionNaming:InlineDiffViewer.kt$@Composable fun InlineDiffViewer( fileName: String, diffContent: String, additions: Int = 0, deletions: Int = 0, modifier: Modifier = Modifier ) FunctionNaming:InlineDiffViewer.kt$@Composable fun PatchDiffViewer( files: List<String>, getDiffContent: suspend (String) -> String?, modifier: Modifier = Modifier ) FunctionNaming:InlineDiffViewer.kt$@Composable private fun InlineDiffLineRow(line: ParsedDiffLine) @@ -120,6 +135,7 @@ FunctionNaming:LicensesScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun LicensesScreen( onNavigateBack: () -> Unit, viewModel: LicensesViewModel = koinViewModel(), ) FunctionNaming:MainTabScreen.kt$@Composable fun MainTabScreen( onDisconnect: () -> Unit, modifier: Modifier = Modifier ) FunctionNaming:MainTabScreen.kt$@Composable private fun FilesWorkspaceOption( title: String, subtitle: String, marker: String, onClick: () -> Unit, modifier: Modifier = Modifier, ) + FunctionNaming:MainTabScreen.kt$@Composable private fun StartWorkActionRow( label: String, description: String, marker: String, onClick: () -> Unit, ) FunctionNaming:MessageBlockUtils.kt$@Composable internal fun MessageBlockView( block: MessageBlock, onToolApprove: (String) -> Unit, onToolDeny: (String) -> Unit, onToolAlways: (String) -> Unit, onOpenSubSession: ((String) -> Unit)? = null, defaultToolWidgetState: ToolWidgetState = ToolWidgetState.COMPACT, pendingPermissionsByCallId: Map<String, Permission> = emptyMap(), onRevert: ((String) -> Unit)? = null ) FunctionNaming:ModelAgentSelector.kt$@Composable private fun ReasoningEffortSelect( efforts: List<String>, selectedEffort: String?, onEffortSelected: (String?) -> Unit, ) FunctionNaming:ModelAgentSelector.kt$@Composable private fun TuiFilterTab( text: String, selected: Boolean, onClick: () -> Unit ) @@ -151,6 +167,7 @@ FunctionNaming:ServerScreen.kt$@Composable private fun DiscoveredServersSection( servers: List<DiscoveredServer>, discoveryState: DiscoveryState, isConnecting: Boolean, onServerClick: (DiscoveredServer) -> Unit ) FunctionNaming:ServerScreen.kt$@Composable private fun RecentServersSection( servers: List<RecentServer>, isConnecting: Boolean, onServerClick: (RecentServer) -> Unit, onRemoveServer: (RecentServer) -> Unit ) FunctionNaming:ServerScreen.kt$@Composable private fun RemoteServerSection( url: String, username: String, password: String, allowInsecure: Boolean, isConnecting: Boolean, onUrlChange: (String) -> Unit, onUsernameChange: (String) -> Unit, onPasswordChange: (String) -> Unit, onAllowInsecureChange: (Boolean) -> Unit, onConnect: () -> Unit ) + FunctionNaming:ServerScreen.kt$@Composable private fun SavedServersSection( servers: List<SavedServer>, isConnecting: Boolean, hasOpenTabs: Boolean, onServerClick: (SavedServer) -> Unit, onRemoveServer: (SavedServer) -> Unit, ) FunctionNaming:ServerScreen.kt$@Composable private fun ScanningIndicator() FunctionNaming:ServerScreen.kt$@Composable private fun ServerSetupHelpSection() FunctionNaming:ServerScreen.kt$@Composable private fun SetupCodeBlock(command: String) @@ -196,8 +213,8 @@ FunctionNaming:StreamingMarkdown.kt$@Composable private fun MarkdownText( text: AnnotatedString, style: TextStyle, colors: MarkdownRenderColors, modifier: Modifier = Modifier, ) FunctionNaming:SyntaxHighlightedCode.kt$@Composable fun SyntaxHighlightedCode( code: String, filename: String, modifier: Modifier = Modifier, showLineNumbers: Boolean = true, fontSize: Int = 12, selectable: Boolean = false, ) FunctionNaming:TabBar.kt$@Composable fun TabBar( tabs: List<TabInstance>, activeTabId: String?, tabTitles: Map<String, String>, tabIcons: Map<String, ImageVector>, tabConnectionStates: Map<String, SessionConnectionState>, onTabClick: (String) -> Unit, onTabClose: (String) -> Unit, onAddClick: () -> Unit, modifier: Modifier = Modifier ) - FunctionNaming:TabBar.kt$@Composable private fun TabIndicator( title: String, icon: ImageVector, connectionState: SessionConnectionState?, isActive: Boolean, onClick: () -> Unit, onClose: () -> Unit, modifier: Modifier = Modifier ) - FunctionNaming:TabNavHost.kt$@Composable fun TabNavHost( navController: NavHostController, tabManager: TabManager, tabId: String, onDisconnect: () -> Unit, onNewFilesTab: () -> Unit = {}, onNewTerminalTab: () -> Unit = {}, onCloseTab: () -> Unit = {}, isActiveTab: Boolean = true, startRoute: String = Screen.Sessions.route, workspaceOwner: WorkspaceRepositoryOwner, onConnectionStateChanged: ((SessionConnectionState?) -> Unit)? = null, modifier: androidx.compose.ui.Modifier = androidx.compose.ui.Modifier ) + FunctionNaming:TabBar.kt$@Composable private fun TabIndicator( title: String, icon: ImageVector, connectionState: SessionConnectionState?, isActive: Boolean, closeable: Boolean = true, onClick: () -> Unit, onClose: () -> Unit, modifier: Modifier = Modifier ) + FunctionNaming:TabNavHost.kt$@Composable fun TabNavHost( navController: NavHostController, tabManager: TabManager, tabId: String, onDisconnect: () -> Unit, onNewFilesTab: () -> Unit = {}, onNewTerminalTab: () -> Unit = {}, onCloseTab: () -> Unit = {}, isActiveTab: Boolean = true, startRoute: String = Screen.Sessions.route, workspaceOwner: WorkspaceRepositoryOwner, serverRef: ServerRef, onConnectionStateChanged: ((SessionConnectionState?) -> Unit)? = null, modifier: androidx.compose.ui.Modifier = androidx.compose.ui.Modifier ) FunctionNaming:TabNavHost.kt$@Composable private fun TouchWorkspaceViewModel( owner: NavBackStackEntry, navController: NavHostController, workspaceRoute: String, workspaceOwner: WorkspaceRepositoryOwner, destinationRoute: String?, ): WorkspaceViewModel FunctionNaming:TerminalScreen.kt$@Composable fun TerminalScreen( viewModel: TerminalViewModel = koinViewModel(), onPtyLoaded: ((ptyId: String, ptyTitle: String) -> Unit)? = null, ) FunctionNaming:TermuxExtraKeysBar.kt$@Composable fun TermuxExtraKeysBar( onKeyPress: (String) -> Unit, ctrlActive: Boolean, altActive: Boolean, onCtrlToggle: () -> Unit, onAltToggle: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, onPaste: (() -> Unit)? = null, ) @@ -263,6 +280,10 @@ FunctionNaming:VisualSettingsScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ThemeSelector( selected: String, options: List<Pair<String, String>>, onSelect: (String) -> Unit ) FunctionNaming:VisualSettingsScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ToolWidgetStateSelector( selected: String, onSelect: (String) -> Unit ) ImportOrdering:ConnectionManager.kt$import dev.blazelight.p4oc.BuildConfig import dev.blazelight.p4oc.core.datastore.SettingsDataStore import dev.blazelight.p4oc.core.log.AppLog import dev.blazelight.p4oc.data.remote.dto.ProjectDto import dev.blazelight.p4oc.data.remote.mapper.EventMapper import dev.blazelight.p4oc.domain.server.ScopedEvent import dev.blazelight.p4oc.domain.server.ServerGeneration import dev.blazelight.p4oc.domain.server.ServerRef import dev.blazelight.p4oc.domain.server.WorkspaceKey import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import kotlinx.serialization.json.Json import okhttp3.ConnectionPool import okhttp3.Credentials import okhttp3.Dispatcher import okhttp3.Interceptor import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit import retrofit2.converter.kotlinx.serialization.asConverterFactory import java.util.concurrent.TimeUnit + ImportOrdering:HomeScreen.kt$import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Terminal import androidx.compose.material.icons.filled.ViewList import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.platform.testTag import dev.blazelight.p4oc.ui.theme.LocalOpenCodeTheme import dev.blazelight.p4oc.domain.server.WorkspaceKey import dev.blazelight.p4oc.ui.theme.Spacing import dev.blazelight.p4oc.ui.theme.TuiShapes + ImportOrdering:SettingsDataStore.kt$import android.content.Context import androidx.datastore.core.DataMigration import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.* import androidx.datastore.preferences.preferencesDataStore import dev.blazelight.p4oc.core.log.AppLog import dev.blazelight.p4oc.core.security.CredentialStore import dev.blazelight.p4oc.core.network.ServerUrl import dev.blazelight.p4oc.data.remote.dto.ModelInput import dev.blazelight.p4oc.domain.server.WorkspaceKey import dev.blazelight.p4oc.domain.session.SessionId import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json + ImportOrdering:TabNavHost.kt$import android.net.Uri import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.compose.animation.* import androidx.compose.animation.core.tween import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import androidx.navigation.NavBackStackEntry import androidx.navigation.NavHostController import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.navigation import androidx.navigation.navArgument import dev.blazelight.p4oc.core.datastore.SettingsDataStore import dev.blazelight.p4oc.core.datastore.VisualSettings import dev.blazelight.p4oc.domain.model.SessionConnectionState import dev.blazelight.p4oc.domain.server.WorkspaceKey import dev.blazelight.p4oc.domain.server.ServerRef import dev.blazelight.p4oc.ui.navigation.Screen import dev.blazelight.p4oc.ui.screens.chat.ChatScreen import dev.blazelight.p4oc.ui.screens.diff.DiffViewerScreen import dev.blazelight.p4oc.ui.screens.diff.SessionDiffScreen import dev.blazelight.p4oc.ui.screens.files.FileExplorerScreen import dev.blazelight.p4oc.ui.screens.files.FileViewerScreen import dev.blazelight.p4oc.ui.screens.files.FilesViewModel import dev.blazelight.p4oc.ui.screens.home.HomeScreen import dev.blazelight.p4oc.ui.screens.home.HomeSummaryBuilder import dev.blazelight.p4oc.ui.screens.projects.ProjectsScreen import dev.blazelight.p4oc.ui.screens.sessions.SessionListScreen import dev.blazelight.p4oc.ui.screens.sessions.SessionListViewModel import dev.blazelight.p4oc.ui.screens.settings.* import dev.blazelight.p4oc.ui.screens.terminal.TerminalScreen import dev.blazelight.p4oc.ui.workspace.WorkspaceRepositoryOwner import dev.blazelight.p4oc.ui.workspace.WorkspaceViewModel import org.koin.androidx.compose.koinViewModel import org.koin.compose.koinInject import org.koin.core.parameter.parametersOf + ImportOrdering:TabState.kt$import dev.blazelight.p4oc.domain.model.SessionConnectionState import dev.blazelight.p4oc.domain.server.ServerRef import dev.blazelight.p4oc.ui.navigation.Screen import dev.blazelight.p4oc.domain.server.WorkspaceKey import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import java.util.UUID Indentation:ChatScreen.kt$ InstanceOfCheckForException:DialogQueueManager.kt$DialogQueueManager$e is CancellationException LargeClass:SessionRepositoryImpl.kt$SessionRepositoryImpl : SessionRepository @@ -290,6 +311,7 @@ LongMethod:FileExplorerScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun FileExplorerScreen( viewModel: FilesViewModel, workspaceKey: WorkspaceKey?, onFileClick: (String) -> Unit, onNavigateBack: () -> Unit, onSwitchWorkspace: () -> Unit = {}, ) LongMethod:FilePickerDialog.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun FilePickerDialog( files: List<FileNode>, currentPath: String, isLoading: Boolean, error: String?, selectedFiles: List<SelectedFile>, onNavigateTo: (String) -> Unit, onNavigateUp: () -> Unit, onFileSelected: (FileNode) -> Unit, onFileDeselected: (String) -> Unit, onUploadClick: () -> Unit, onConfirm: () -> Unit, onDismiss: () -> Unit ) LongMethod:FileViewerScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun FileViewerScreen( path: String, viewModel: FilesViewModel, onNavigateBack: () -> Unit ) + LongMethod:HomeScreen.kt$@Composable fun HomeScreen( summary: HomeSummaryState, onBrowseSessions: () -> Unit, onOpenFiles: () -> Unit, onOpenTerminal: () -> Unit, modifier: Modifier = Modifier, onWorkspaceSelected: (WorkspaceSummary) -> Unit = {}, ) LongMethod:InlineDiffViewer.kt$@Composable fun InlineDiffViewer( fileName: String, diffContent: String, additions: Int = 0, deletions: Int = 0, modifier: Modifier = Modifier ) LongMethod:InlineDiffViewer.kt$@Composable fun PatchDiffViewer( files: List<String>, getDiffContent: suspend (String) -> String?, modifier: Modifier = Modifier ) LongMethod:InlinePermissionPrompt.kt$@Composable fun InlinePermissionPrompt( permission: Permission, onAllow: () -> Unit, onAlways: () -> Unit, onReject: () -> Unit, modifier: Modifier = Modifier ) @@ -314,8 +336,11 @@ LongMethod:ProviderConfigScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ProviderConfigScreen( viewModel: ProviderConfigViewModel = koinViewModel(), onNavigateBack: () -> Unit ) LongMethod:ServerScreen.kt$@Composable private fun DiscoveredServersSection( servers: List<DiscoveredServer>, discoveryState: DiscoveryState, isConnecting: Boolean, onServerClick: (DiscoveredServer) -> Unit ) LongMethod:ServerScreen.kt$@Composable private fun RemoteServerSection( url: String, username: String, password: String, allowInsecure: Boolean, isConnecting: Boolean, onUrlChange: (String) -> Unit, onUsernameChange: (String) -> Unit, onPasswordChange: (String) -> Unit, onAllowInsecureChange: (Boolean) -> Unit, onConnect: () -> Unit ) + LongMethod:ServerScreen.kt$@Composable private fun SavedServersSection( servers: List<SavedServer>, isConnecting: Boolean, hasOpenTabs: Boolean, onServerClick: (SavedServer) -> Unit, onRemoveServer: (SavedServer) -> Unit, ) LongMethod:ServerScreen.kt$@Composable private fun ServerSetupHelpSection() LongMethod:ServerScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ServerScreen( viewModel: ServerViewModel = koinViewModel(), onNavigateToSessions: () -> Unit, onNavigateToProjects: () -> Unit, onSettings: () -> Unit ) + LongMethod:ServerViewModel.kt$ServerViewModel$fun connectToRemote() + LongMethod:ServerViewModelIssue31Test.kt$ServerViewModelIssue31Test$@Test fun `connectToRemote persists no-port https url without appending opencode port`() LongMethod:SessionDiffScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun SessionDiffScreen( sessionId: String, workspaceClient: WorkspaceClient, onNavigateBack: () -> Unit, ) LongMethod:SessionListScreen.kt$@OptIn(ExperimentalFoundationApi::class) @Composable private fun SessionCard( session: Session, projectId: String?, projectName: String?, showProjectChip: Boolean, status: SessionStatus?, presence: SessionPresence, isShared: Boolean, onClick: () -> Unit, onDelete: () -> Unit, onRename: () -> Unit, onShare: () -> Unit, onViewChanges: () -> Unit, onSummarize: () -> Unit, onProjectClick: (String) -> Unit, childCount: Int = 0, isExpanded: Boolean = false, onExpandToggle: (() -> Unit)? = null, isSubAgent: Boolean = false ) LongMethod:SessionListScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable private fun NewSessionDialog( projects: List<ProjectInfo>, defaultProjectId: String? = null, initialUseCustomDirectory: Boolean = false, onDismiss: () -> Unit, onCreate: (String?, String?) -> Unit ) @@ -333,7 +358,8 @@ LongMethod:StreamingMarkdown.kt$internal fun parseMarkdownBlocks(text: String): List<MarkdownBlock> LongMethod:StreamingMarkdown.kt$private fun inlineMarkdown(text: String, colors: MarkdownRenderColors): AnnotatedString LongMethod:SyntaxHighlightedCode.kt$@Composable fun SyntaxHighlightedCode( code: String, filename: String, modifier: Modifier = Modifier, showLineNumbers: Boolean = true, fontSize: Int = 12, selectable: Boolean = false, ) - LongMethod:TabNavHost.kt$@Composable fun TabNavHost( navController: NavHostController, tabManager: TabManager, tabId: String, onDisconnect: () -> Unit, onNewFilesTab: () -> Unit = {}, onNewTerminalTab: () -> Unit = {}, onCloseTab: () -> Unit = {}, isActiveTab: Boolean = true, startRoute: String = Screen.Sessions.route, workspaceOwner: WorkspaceRepositoryOwner, onConnectionStateChanged: ((SessionConnectionState?) -> Unit)? = null, modifier: androidx.compose.ui.Modifier = androidx.compose.ui.Modifier ) + LongMethod:TabBar.kt$@Composable fun TabBar( tabs: List<TabInstance>, activeTabId: String?, tabTitles: Map<String, String>, tabIcons: Map<String, ImageVector>, tabConnectionStates: Map<String, SessionConnectionState>, onTabClick: (String) -> Unit, onTabClose: (String) -> Unit, onAddClick: () -> Unit, modifier: Modifier = Modifier ) + LongMethod:TabNavHost.kt$@Composable fun TabNavHost( navController: NavHostController, tabManager: TabManager, tabId: String, onDisconnect: () -> Unit, onNewFilesTab: () -> Unit = {}, onNewTerminalTab: () -> Unit = {}, onCloseTab: () -> Unit = {}, isActiveTab: Boolean = true, startRoute: String = Screen.Sessions.route, workspaceOwner: WorkspaceRepositoryOwner, serverRef: ServerRef, onConnectionStateChanged: ((SessionConnectionState?) -> Unit)? = null, modifier: androidx.compose.ui.Modifier = androidx.compose.ui.Modifier ) LongMethod:TerminalScreen.kt$@Composable fun TerminalScreen( viewModel: TerminalViewModel = koinViewModel(), onPtyLoaded: ((ptyId: String, ptyTitle: String) -> Unit)? = null, ) LongMethod:TodoTracker.kt$@Composable private fun TuiTodoItem(todo: Todo) LongMethod:TodoTracker.kt$@Composable private fun TuiTodoList(todos: List<Todo>) @@ -361,6 +387,9 @@ LongParameterList:ExpandedWidgets.kt$( tool: Part.Tool, onClick: (() -> Unit)?, showApprovalActions: Boolean, approvalRequestId: String = tool.callID, onToolApprove: (String) -> Unit, onToolDeny: (String) -> Unit, modifier: Modifier = Modifier ) LongParameterList:ExpandedWidgets.kt$( tool: Part.Tool, onClick: (() -> Unit)?, showApprovalActions: Boolean, approvalRequestId: String = tool.callID, onToolApprove: (String) -> Unit, onToolDeny: (String) -> Unit, onOpenSubSession: ((String) -> Unit)?, modifier: Modifier = Modifier ) LongParameterList:FilePickerDialog.kt$( files: List<FileNode>, currentPath: String, isLoading: Boolean, error: String?, selectedFiles: List<SelectedFile>, onNavigateTo: (String) -> Unit, onNavigateUp: () -> Unit, onFileSelected: (FileNode) -> Unit, onFileDeselected: (String) -> Unit, onUploadClick: () -> Unit, onConfirm: () -> Unit, onDismiss: () -> Unit ) + LongParameterList:HomeScreen.kt$( label: String, description: String, icon: @Composable () -> Unit, onClick: () -> Unit, testTag: String, modifier: Modifier = Modifier, ) + LongParameterList:HomeScreen.kt$( summary: HomeSummaryState, onBrowseSessions: () -> Unit, onOpenFiles: () -> Unit, onOpenTerminal: () -> Unit, modifier: Modifier = Modifier, onWorkspaceSelected: (WorkspaceSummary) -> Unit = {}, ) + LongParameterList:HomeScreen.kt$( workspace: WorkspaceSummary, openWork: List<OpenWorkSummary>, onBack: () -> Unit, onOpenFiles: () -> Unit, onOpenTerminal: () -> Unit, modifier: Modifier = Modifier, ) LongParameterList:LicensesScreen.kt$( entry: LicenseEntry, expanded: Boolean, fullText: String?, isLoading: Boolean, onToggle: () -> Unit, onOpenUpstream: () -> Unit, ) LongParameterList:MessageBlockUtils.kt$( block: MessageBlock, onToolApprove: (String) -> Unit, onToolDeny: (String) -> Unit, onToolAlways: (String) -> Unit, onOpenSubSession: ((String) -> Unit)? = null, defaultToolWidgetState: ToolWidgetState = ToolWidgetState.COMPACT, pendingPermissionsByCallId: Map<String, Permission> = emptyMap(), onRevert: ((String) -> Unit)? = null ) LongParameterList:ModelAgentSelector.kt$( availableModels: List<Pair<String, ModelDto>>, selectedModel: ModelInput?, favoriteModels: Set<ModelInput>, recentModels: List<ModelInput>, onModelSelected: (ModelInput) -> Unit, onToggleFavorite: (ModelInput) -> Unit, onDismiss: () -> Unit ) @@ -373,13 +402,15 @@ LongParameterList:SessionListScreen.kt$( session: Session, projectId: String?, projectName: String?, showProjectChip: Boolean, status: SessionStatus?, presence: SessionPresence, isShared: Boolean, onClick: () -> Unit, onDelete: () -> Unit, onRename: () -> Unit, onShare: () -> Unit, onViewChanges: () -> Unit, onSummarize: () -> Unit, onProjectClick: (String) -> Unit, childCount: Int = 0, isExpanded: Boolean = false, onExpandToggle: (() -> Unit)? = null, isSubAgent: Boolean = false ) LongParameterList:SessionListScreen.kt$( viewModel: SessionListViewModel = koinViewModel(), filterProjectId: String? = null, onSessionClick: (sessionId: String, directory: String?) -> Unit, onNewSession: (sessionId: String, directory: String?) -> Unit, onSettings: () -> Unit, onProjects: () -> Unit = {}, onProjectClick: (directory: String) -> Unit = {}, onViewChanges: (sessionId: String) -> Unit = {}, onCreateSessionInWorkspace: (title: String?, directory: String?) -> Unit = { title, directory -> viewModel.createSession(title, directory) }, autoCreateSession: Boolean = false, autoCreateSessionTitle: String? = null, autoCreateSessionDirectory: String? = null, onAutoCreateSessionConsumed: () -> Unit = {}, onNavigateBack: (() -> Unit)? = null ) LongParameterList:SessionWorkspaceClient.kt$SessionWorkspaceClient$( directory: String?, scope: String? = null, roots: Boolean? = null, start: Long? = null, search: String? = null, limit: Int? = null, ) + LongParameterList:SettingsDataStore.kt$SavedServerRegistry$( url: String, name: String, username: String? = null, allowInsecure: Boolean = false, pinned: Boolean = false, defaultWorkspace: String? = null, lastConnectedAt: Long? = null, ) + LongParameterList:SettingsDataStore.kt$SettingsDataStore$( url: String, name: String, username: String? = null, password: String? = null, allowInsecure: Boolean = false, pinned: Boolean = false, defaultWorkspace: String? = null, lastConnectedAt: Long? = null, ) LongParameterList:SettingsScreen.kt$( icon: ImageVector, title: String, modifier: Modifier = Modifier, subtitle: String? = null, onClick: (() -> Unit)? = null, showChevron: Boolean = false, tint: androidx.compose.ui.graphics.Color? = null, enabled: Boolean = true, testTag: String? = null ) LongParameterList:SettingsScreen.kt$( viewModel: SettingsViewModel = koinViewModel(), onNavigateBack: () -> Unit, onDisconnect: () -> Unit, onProviderConfig: () -> Unit = {}, onChatSettings: () -> Unit = {}, onVisualSettings: () -> Unit = {}, onAgentsConfig: () -> Unit = {}, onSkills: () -> Unit = {}, onNotificationSettings: () -> Unit = {}, onConnectionSettings: () -> Unit = {}, onLicenses: () -> Unit = {} ) LongParameterList:SoraCodeEditorView.kt$( initialContent: String, contentGeneration: Int, showLineNumbers: Boolean, editable: Boolean, textSizeSp: Float, filename: String, modifier: Modifier = Modifier, onTextChange: (String) -> Unit, onSelectionChange: (line: Int, column: Int) -> Unit = { _, _ -> }, testTag: String? = null ) LongParameterList:SyntaxHighlightedCode.kt$( code: String, filename: String, modifier: Modifier = Modifier, showLineNumbers: Boolean = true, fontSize: Int = 12, selectable: Boolean = false, ) LongParameterList:TabBar.kt$( tabs: List<TabInstance>, activeTabId: String?, tabTitles: Map<String, String>, tabIcons: Map<String, ImageVector>, tabConnectionStates: Map<String, SessionConnectionState>, onTabClick: (String) -> Unit, onTabClose: (String) -> Unit, onAddClick: () -> Unit, modifier: Modifier = Modifier ) - LongParameterList:TabBar.kt$( title: String, icon: ImageVector, connectionState: SessionConnectionState?, isActive: Boolean, onClick: () -> Unit, onClose: () -> Unit, modifier: Modifier = Modifier ) - LongParameterList:TabNavHost.kt$( navController: NavHostController, tabManager: TabManager, tabId: String, onDisconnect: () -> Unit, onNewFilesTab: () -> Unit = {}, onNewTerminalTab: () -> Unit = {}, onCloseTab: () -> Unit = {}, isActiveTab: Boolean = true, startRoute: String = Screen.Sessions.route, workspaceOwner: WorkspaceRepositoryOwner, onConnectionStateChanged: ((SessionConnectionState?) -> Unit)? = null, modifier: androidx.compose.ui.Modifier = androidx.compose.ui.Modifier ) + LongParameterList:TabBar.kt$( title: String, icon: ImageVector, connectionState: SessionConnectionState?, isActive: Boolean, closeable: Boolean = true, onClick: () -> Unit, onClose: () -> Unit, modifier: Modifier = Modifier ) + LongParameterList:TabNavHost.kt$( navController: NavHostController, tabManager: TabManager, tabId: String, onDisconnect: () -> Unit, onNewFilesTab: () -> Unit = {}, onNewTerminalTab: () -> Unit = {}, onCloseTab: () -> Unit = {}, isActiveTab: Boolean = true, startRoute: String = Screen.Sessions.route, workspaceOwner: WorkspaceRepositoryOwner, serverRef: ServerRef, onConnectionStateChanged: ((SessionConnectionState?) -> Unit)? = null, modifier: androidx.compose.ui.Modifier = androidx.compose.ui.Modifier ) LongParameterList:TermuxExtraKeysBar.kt$( onKeyPress: (String) -> Unit, ctrlActive: Boolean, altActive: Boolean, onCtrlToggle: () -> Unit, onAltToggle: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, onPaste: (() -> Unit)? = null, ) LongParameterList:ToolCallWidget.kt$( tool: Part.Tool, onClick: (() -> Unit)?, showApprovalActions: Boolean = true, approvalRequestId: String = tool.callID, onToolApprove: (String) -> Unit, onToolDeny: (String) -> Unit, onOpenSubSession: ((String) -> Unit)? = null, modifier: Modifier = Modifier ) LongParameterList:ToolGroupWidget.kt$( tools: List<Part.Tool>, defaultState: ToolWidgetState, pendingPermissionIdsByCallId: Map<String, String> = emptyMap(), onToolApprove: (String) -> Unit, onToolDeny: (String) -> Unit, onOpenSubSession: ((String) -> Unit)? = null, modifier: Modifier = Modifier ) @@ -404,9 +435,7 @@ MagicNumber:ChatViewModel.kt$ChatViewModel$5000 MagicNumber:ConnectionManager.kt$ConnectionManager$1000L MagicNumber:ConnectionManager.kt$ConnectionManager$30 - MagicNumber:ConnectionManager.kt$ConnectionManager$443 MagicNumber:ConnectionManager.kt$ConnectionManager$60 - MagicNumber:ConnectionManager.kt$ConnectionManager$80 MagicNumber:ConnectionManager.kt$ConnectionManager$8_000 MagicNumber:ConnectionSettingsScreen.kt$0.5f MagicNumber:DiffViewerScreen.kt$4 @@ -487,6 +516,7 @@ MagicNumber:HapticFeedback.kt$HapticFeedback$120L MagicNumber:HapticFeedback.kt$HapticFeedback$45L MagicNumber:HapticFeedback.kt$HapticFeedback$80L + MagicNumber:HomeScreen.kt$6 MagicNumber:InlineDiffViewer.kt$4 MagicNumber:MdnsDiscoveryManager.kt$MdnsDiscoveryManager$200 MagicNumber:ModelAgentSelector.kt$0.85f @@ -586,15 +616,20 @@ MatchingDeclarationName:NotificationSettingsScreen.kt$NotificationSettingsViewModel : ViewModel MatchingDeclarationName:TodoDtos.kt$TodoDto MatchingDeclarationName:VisualSettingsScreen.kt$VisualSettingsViewModel : ViewModel + MaxLineLength:AttentionBadgeRegistryTest.kt$AttentionBadgeRegistryTest$registry.set(AttentionSignal(AttentionKey(alpha, workspace, "tab-a"), AttentionSeverity.Warning, "awaiting input")) MaxLineLength:ChatViewModelTest.kt$ChatViewModelTest$"file:/test/src/My%20File%20%25/%C3%BCmlaut/%E3%81%93%E3%82%93%E3%81%AB%E3%81%A1%E3%81%AF/hash%23query%3F.kt" MaxLineLength:ConnectionManager.kt$ConnectionManager$AppLog.w(TAG, "SSE remained in Error after ${settings.reconnectTimeoutSeconds}s; escalating to Disconnected: ${state.message}") MaxLineLength:ConnectionManager.kt$ConnectionManager$level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.NONE MaxLineLength:ConnectionManager.kt$ConnectionManager$level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.HEADERS else HttpLoggingInterceptor.Level.NONE MaxLineLength:FileExplorerScreen.kt$leadingIcon = { Icon(Icons.AutoMirrored.Filled.NoteAdd, contentDescription = null, tint = theme.textMuted) } + MaxLineLength:HomeScreen.kt$body = "${summary.openWork.size} bounded open work item${if (summary.openWork.size == 1) "" else "s"}; ${summary.workspaces.size} workspace summar${if (summary.workspaces.size == 1) "y" else "ies"} loaded without chat history." + MaxLineLength:HomeScreen.kt$body = "Attention appears as compact badges and dots on Home, tabs, servers, and workspaces — not as a feed." + MaxLineLength:HomeScreen.kt$description = "${workspace.serverRef.displayName} · ${workspace.openTabCount} open item${if (workspace.openTabCount == 1) "" else "s"}" MaxLineLength:InlineDiffViewer.kt$currentDiffContent?.let { ParsedDiffParser.parse(it).allHunks().flatMap { hunk -> hunk.lines } } MaxLineLength:KoinModules.kt$"Workspace generation ${generation.value} does not match active generation ${activeGeneration?.value ?: "<none>"}" MaxLineLength:KoinModules.kt$"Workspace server ${serverRef.endpointKey} does not match active server ${activeServerRef.endpointKey}" MaxLineLength:KoinModules.kt$?: + MaxLineLength:MainTabScreen.kt$"Target: ${workspaceLabel(targetWorkspace, tabTitleLabels) ?: workspaceSubtitle(targetWorkspace)}" MaxLineLength:MainTabScreen.kt$restoreError = "Saved tabs belong to ${result.persistedEndpointKey}, not ${result.activeEndpointKey}. Starting fresh." MaxLineLength:MainTabScreen.kt$val hasUnread = sessionState.responseCompletedToken > readToken && sessionState.status !is SessionStatus.Busy MaxLineLength:MessageDtos.kt$MessageErrorDto$val name: String @@ -603,6 +638,9 @@ MaxLineLength:OfishSessionFactory.kt$OfishSessionFactory$val timestamp = OfishSessionNames.parseTimestamp(session.title) ?: (session.time.updated ?: session.time.created) MaxLineLength:ParsedDiff.kt$ParsedDiffParser$if MaxLineLength:PartDtos.kt$PartDto$val type: String + MaxLineLength:ServerConnectionRegistry.kt$ServerConnectionRegistry$fun connectionState(serverRef: ServerRef): StateFlow<ConnectionState> + MaxLineLength:ServerConnectionRegistryTest.kt$ServerConnectionRegistryTest$coEvery { manager.connect(server.toServerConfig(), any()) } returns Result.failure(IllegalStateException(message)) + MaxLineLength:ServerScreen.kt$text = "${server.endpoint} · ${if (server.allowInsecure) "TLS checks off" else "TLS checks on"}" MaxLineLength:SessionListScreen.kt$text MaxLineLength:SessionListViewModelTest.kt$SessionListViewModelTest$Pair("/project", "apple") to listOf(FakeWorkspaceClient.sessionDto("project", title = "apple project", directory = "/project")) MaxLineLength:SessionListViewModelTest.kt$SessionListViewModelTest$Pair(null, "apple") to listOf(FakeWorkspaceClient.sessionDto("global", title = "apple global", directory = "/global")) @@ -621,14 +659,20 @@ MaxLineLength:SessionRepositoryImplTest.kt$SessionRepositoryImplTest$FakeWorkspaceClient.sessionDto(id = "shared", title = "older match", directory = "/global", updatedAt = 1L) MaxLineLength:SessionRepositoryImplTest.kt$SessionRepositoryImplTest$projects = listOf(FakeWorkspaceClient.projectDto("p1", "/repo/p1"), FakeWorkspaceClient.projectDto("p2", "/repo/p2")) MaxLineLength:SettingsDataStore.kt$SettingsDataStore$?: + MaxLineLength:TabManager.kt$TabManager$return missingServerEndpointKeys.firstOrNull()?.let { RestoreResult.MissingServer(it) } ?: RestoreResult.Empty + MaxLineLength:TabManager.kt$TabManager$val serverEndpointKey = persisted.resolvedServerEndpointKey(state.serverEndpointKey) ?: return@mapNotNull null + MaxLineLength:TabManagerPersistenceTest.kt$TabManagerPersistenceTest$assertEquals(listOf(alpha.endpointKey, beta.endpointKey, local.endpointKey), restoredWorkTabs.map { it.serverEndpointKey }) + MaxLineLength:TabManagerPersistenceTest.kt$TabManagerPersistenceTest$assertEquals(listOf(server.endpointKey, otherServer.endpointKey), listOf(first.serverEndpointKey, second.serverEndpointKey)) MaxLineLength:TabNavHost.kt$TouchWorkspaceViewModel(backStackEntry, navController, workspaceRoute, workspaceOwner, backStackEntry.destination.route) MaxLineLength:ToolGroupWidget.kt$onApprove = { onToolApprove(pendingPermissionIdsByCallId[tool.callID] ?: tool.callID) } MaxLineLength:ToolGroupWidget.kt$toolList.any { it.state is ToolState.Pending || it.callID in pendingPermissionIdsByCallId.keys } -> AggregateToolState.PENDING MaxLineLength:WorkspaceFileRepositoryTest.kt$WorkspaceFileRepositoryTest$uri = "file:///src/My%20File%20%25/%C3%BCmlaut/%E3%81%93%E3%82%93%E3%81%AB%E3%81%A1%E3%81%AF/hash%23query%3F.kt" MaxLineLength:WorkspaceRepositoryOwner.kt$WorkspaceRepositoryOwner$"WorkspaceRepositoryOwner.$event tabId=$tabId workspaceKey=${workspace.key} server=${workspace.server.endpointKey} generation=${generation.value} identity=$identityHash" + MaximumLineLength:AttentionBadgeRegistryTest.kt$AttentionBadgeRegistryTest$ MaximumLineLength:ChatViewModelTest.kt$ChatViewModelTest$ MaximumLineLength:ConnectionManager.kt$ConnectionManager$ MaximumLineLength:FileExplorerScreen.kt$ + MaximumLineLength:HomeScreen.kt$ MaximumLineLength:InlineDiffViewer.kt$ MaximumLineLength:KoinModules.kt$ MaximumLineLength:MainTabScreen.kt$ @@ -637,11 +681,16 @@ MaximumLineLength:OfishSessionFactory.kt$OfishSessionFactory$ MaximumLineLength:ParsedDiff.kt$ParsedDiffParser$ MaximumLineLength:PartDtos.kt$PartDto$ + MaximumLineLength:ServerConnectionRegistry.kt$ServerConnectionRegistry$ + MaximumLineLength:ServerConnectionRegistryTest.kt$ServerConnectionRegistryTest$ + MaximumLineLength:ServerScreen.kt$ MaximumLineLength:SessionListScreen.kt$ MaximumLineLength:SessionListViewModelTest.kt$SessionListViewModelTest$ MaximumLineLength:SessionRepositoryImpl.kt$SessionRepositoryImpl$ MaximumLineLength:SessionRepositoryImplTest.kt$SessionRepositoryImplTest$ MaximumLineLength:SettingsDataStore.kt$SettingsDataStore$ + MaximumLineLength:TabManager.kt$TabManager$ + MaximumLineLength:TabManagerPersistenceTest.kt$TabManagerPersistenceTest$ MaximumLineLength:TabNavHost.kt$ MaximumLineLength:ToolGroupWidget.kt$ MaximumLineLength:WorkspaceFileRepositoryTest.kt$WorkspaceFileRepositoryTest$ @@ -650,12 +699,15 @@ MultiLineIfElse:FilePickerDialog.kt$stringResource(R.string.empty_folder) NestedBlockDepth:OfishMutationClient.kt$OfishMutationClient$private suspend fun uploadInSession( sessionId: String, path: String, request: FileUploadRequest, capabilities: OfishCapabilities, ): FileOperationResult<FileUploadResult> NestedBlockDepth:StreamingMarkdown.kt$internal fun parseMarkdownBlocks(text: String): List<MarkdownBlock> + NoBlankLineBeforeRbrace:TabManagerPersistenceTest.kt$TabManagerPersistenceTest$ NoBlankLineBeforeRbrace:VisualSettingsScreen.kt$ NoConsecutiveBlankLines:ChatViewModelTest.kt$ChatViewModelTest$ NoConsecutiveBlankLines:ComponentPreviews.kt$ + NoConsecutiveBlankLines:TabState.kt$TabState$ NoSemicolons:CommandPalette.kt$; NoSemicolons:TextMateAnnotatedStringTest.kt$TextMateAnnotatedStringTest.Companion$; NoTrailingSpaces:ToolGroupWidget.kt$ + NoUnusedImports:ServerConnectionRegistryTest.kt$dev.blazelight.p4oc.core.network.ServerConnectionRegistryTest.kt NoWildcardImports:AgentsConfigScreen.kt$import androidx.compose.foundation.layout.* NoWildcardImports:AgentsConfigScreen.kt$import androidx.compose.material.icons.filled.* NoWildcardImports:AgentsConfigScreen.kt$import androidx.compose.material3.* @@ -843,7 +895,7 @@ ReturnCount:StreamingMarkdown.kt$private fun parseTable(lines: List<String>, start: Int): ParsedTable? ReturnCount:StreamingMarkdown.kt$private fun parseTableDelimiter(line: String, expectedCells: Int): Int? ReturnCount:TabChatRouteCodec.kt$TabChatRouteCodec$fun decode(route: String): TabChatRoute? - ReturnCount:TabManager.kt$TabManager$fun restoreState(state: PersistedTabState, activeServer: ServerRef): RestoreResult + ReturnCount:TabManager.kt$TabManager$fun restoreState( state: PersistedTabState, availableServers: Map<String, ServerRef>, fallbackServer: ServerRef? = null, ): RestoreResult ReturnCount:TermuxTerminalView.kt$KeyInterceptingContainer$private fun handleKeyDown(event: KeyEvent): Boolean ReturnCount:TermuxTerminalView.kt$TerminalInputView$override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean ReturnCount:TermuxTerminalView.kt$TerminalInputView.<no name provided>$override fun sendKeyEvent(event: KeyEvent): Boolean @@ -855,6 +907,8 @@ ReturnCount:ToolStateExt.kt$private fun parseQuestionOption(json: JsonObject): QuestionOption? ReturnCount:UploadOrchestrator.kt$UploadOrchestrator$private suspend fun uploadOne(index: Int) ReturnCount:UploadVisuals.kt$fun getMimeTypeLabel(mimeType: String?): String + SpacingBetweenDeclarationsWithAnnotations:SettingsDataStore.kt$PersistedWorkspaceKey + SpacingBetweenDeclarationsWithAnnotations:TabManagerPersistenceTest.kt$TabManagerPersistenceTest$@Test fun `pinned Home is leftmost non-closeable and not duplicated`() SwallowedException:ChatInputBar.kt$e: Exception SwallowedException:Mappers.kt$MessageMapper$e: Exception SwallowedException:SettingsDataStore.kt$SettingsDataStore$e: Exception @@ -1050,6 +1104,7 @@ Wrapping:ServerScreen.kt$Text( stringResource(R.string.field_server_url_placeholder), fontFamily = FontFamily.Monospace ) Wrapping:SessionListViewModel.kt$SessionListViewModel$-> Wrapping:SessionListViewModel.kt$SessionListViewModel$it.copy( isLoading = false, loadingText = null, loadingProgress = null, loadingCounts = null, error = "Switch to $directory before creating a session" ) + Wrapping:TabManagerPersistenceTest.kt$TabManagerPersistenceTest$it.serverEndpointKey Wrapping:ToolGroupWidget.kt$it.state is ToolState.Pending || it.callID in pendingPermissionIdsByCallId.keys diff --git a/app/src/main/java/dev/blazelight/p4oc/core/datastore/SettingsDataStore.kt b/app/src/main/java/dev/blazelight/p4oc/core/datastore/SettingsDataStore.kt index 859a78a1..847095f3 100644 --- a/app/src/main/java/dev/blazelight/p4oc/core/datastore/SettingsDataStore.kt +++ b/app/src/main/java/dev/blazelight/p4oc/core/datastore/SettingsDataStore.kt @@ -7,6 +7,7 @@ import androidx.datastore.preferences.core.* import androidx.datastore.preferences.preferencesDataStore import dev.blazelight.p4oc.core.log.AppLog import dev.blazelight.p4oc.core.security.CredentialStore +import dev.blazelight.p4oc.core.network.ServerUrl import dev.blazelight.p4oc.data.remote.dto.ModelInput import dev.blazelight.p4oc.domain.server.WorkspaceKey import dev.blazelight.p4oc.domain.session.SessionId @@ -45,6 +46,7 @@ class SettingsDataStore constructor( const val DEFAULT_THEME_NAME = "catppuccin" private val KEY_ONBOARDING_COMPLETED = booleanPreferencesKey("onboarding_completed") private val KEY_RECENT_SERVERS = stringPreferencesKey("recent_servers") + private val KEY_SAVED_SERVERS = stringPreferencesKey("saved_servers_v1") private val KEY_TAB_STATE = stringPreferencesKey("tab_state_v1") // Visual settings keys @@ -321,6 +323,78 @@ class SettingsDataStore constructor( } } + val savedServers: Flow> = context.dataStore.data.map { prefs -> + savedServersFromPreferences(prefs) + } + + suspend fun getSavedServers(): List = savedServersFromPreferences(context.dataStore.data.first()) + + suspend fun findSavedServer(id: String): SavedServer? = getSavedServers().firstOrNull { it.id == id } + + suspend fun findSavedServerByEndpointKey(endpointKey: String): SavedServer? = + getSavedServers().firstOrNull { it.endpointKey == endpointKey } + + suspend fun upsertSavedServer(server: SavedServer) { + val normalized = SavedServerRegistry.normalize(server) + context.dataStore.edit { prefs -> + val current = savedServersFromPreferences(prefs) + val updated = SavedServerRegistry.upsert(current, normalized) + prefs[KEY_SAVED_SERVERS] = json.encodeToString(updated) + } + } + + suspend fun addSavedServer( + url: String, + name: String, + username: String? = null, + password: String? = null, + allowInsecure: Boolean = false, + pinned: Boolean = false, + defaultWorkspace: String? = null, + lastConnectedAt: Long? = null, + ): SavedServer { + val server = SavedServerRegistry.fromConnection( + url = url, + name = name, + username = username, + allowInsecure = allowInsecure, + pinned = pinned, + defaultWorkspace = defaultWorkspace, + lastConnectedAt = lastConnectedAt, + ) + upsertSavedServer(server) + if (password != null) { + credentialStore.setServerPassword(server.id, password) + credentialStore.setServerPassword(server.endpoint, password) + } + return server + } + + suspend fun updateSavedServer(server: SavedServer) = upsertSavedServer(server) + + suspend fun removeSavedServer(id: String, removeCredentials: Boolean = true) { + var removed: SavedServer? = null + context.dataStore.edit { prefs -> + val current = savedServersFromPreferences(prefs) + removed = current.firstOrNull { it.id == id } + val updated = current.filterNot { it.id == id } + if (updated.isEmpty()) { + prefs.remove(KEY_SAVED_SERVERS) + } else { + prefs[KEY_SAVED_SERVERS] = json.encodeToString(updated) + } + } + if (removeCredentials) { + removed?.let { server -> + credentialStore.removeServerPassword(server.id) + credentialStore.removeServerPassword(server.endpoint) + } + } + } + + suspend fun getSavedServerPassword(server: SavedServer): String? = + credentialStore.getServerPassword(server.id) ?: credentialStore.getServerPassword(server.endpoint) + /** * Add a recent server. Password is stored in CredentialStore, not in the JSON. */ @@ -572,6 +646,43 @@ class SettingsDataStore constructor( } } + private fun parseSavedServersLenient(stored: String): List { + if (stored.isBlank()) return emptyList() + return runCatching { json.decodeFromString>(stored) }.getOrDefault(emptyList()) + } + + private fun savedServersFromPreferences(prefs: Preferences): List { + val stored = prefs[KEY_SAVED_SERVERS].orEmpty() + val saved = parseSavedServersLenient(stored) + + val lastConnection = prefs[KEY_SERVER_URL]?.let { url -> + SavedServerRegistry.fromConnection( + url = url, + name = prefs[KEY_SERVER_NAME].orEmpty(), + username = prefs[KEY_USERNAME], + allowInsecure = prefs[KEY_ALLOW_INSECURE] ?: false, + lastConnectedAt = null, + ) + } + + val recent = prefs[KEY_RECENT_SERVERS] + ?.let(::parseRecentServersLenient) + .orEmpty() + .mapNotNull { recentServer -> + runCatching { + SavedServerRegistry.fromConnection( + url = recentServer.url, + name = recentServer.name, + username = recentServer.username, + allowInsecure = recentServer.allowInsecure, + lastConnectedAt = null, + ) + }.getOrNull() + } + + return SavedServerRegistry.merge(saved + listOfNotNull(lastConnection) + recent) + } + private fun parsePersistedTabState(stored: String): PersistedTabState? = try { migrateLegacyPersistedTabState(stored) ?: json.decodeFromString(stored) } catch (e: Exception) { @@ -590,6 +701,7 @@ class SettingsDataStore constructor( sessionId = tab.sessionId, sessionTitle = tab.sessionTitle, workspaceKey = workspaceKey, + serverEndpointKey = legacy.serverEndpointKey, ) } return PersistedTabState( @@ -639,6 +751,93 @@ data class RecentServer( val allowInsecure: Boolean = false ) +@Serializable +data class SavedServer( + val id: String, + val endpoint: String, + val endpointKey: String, + val displayName: String, + val username: String? = null, + val allowInsecure: Boolean = false, + val pinned: Boolean = false, + val defaultWorkspace: String? = null, + val lastConnectedAt: Long? = null, +) + +internal object SavedServerRegistry { + fun fromConnection( + url: String, + name: String, + username: String? = null, + allowInsecure: Boolean = false, + pinned: Boolean = false, + defaultWorkspace: String? = null, + lastConnectedAt: Long? = null, + ): SavedServer { + val endpoint = ServerUrl.normalizeConnectUrl(url) + ?: throw IllegalArgumentException("Invalid server endpoint: $url") + val endpointKey = ServerUrl.endpointKey(endpoint) + ?: throw IllegalArgumentException("Invalid server endpoint: $url") + return SavedServer( + id = endpointKey, + endpoint = endpoint, + endpointKey = endpointKey, + displayName = name.takeIf { it.isNotBlank() } ?: endpoint, + username = username, + allowInsecure = allowInsecure, + pinned = pinned, + defaultWorkspace = defaultWorkspace?.takeIf { it.isNotBlank() }, + lastConnectedAt = lastConnectedAt, + ) + } + + fun normalize(server: SavedServer): SavedServer { + val endpoint = ServerUrl.normalizeConnectUrl(server.endpoint) + ?: throw IllegalArgumentException("Invalid server endpoint: ${server.endpoint}") + val endpointKey = ServerUrl.endpointKey(endpoint) + ?: throw IllegalArgumentException("Invalid server endpoint: ${server.endpoint}") + return server.copy( + id = server.id.ifBlank { endpointKey }, + endpoint = endpoint, + endpointKey = endpointKey, + displayName = server.displayName.takeIf { it.isNotBlank() } ?: endpoint, + defaultWorkspace = server.defaultWorkspace?.takeIf { it.isNotBlank() }, + ) + } + + fun upsert(current: List, server: SavedServer): List { + val normalized = normalize(server) + val withoutSameIdentity = current.filterNot { + it.id == normalized.id || it.endpointKey == normalized.endpointKey + } + return merge(listOf(normalized) + withoutSameIdentity) + } + + fun merge(servers: List): List { + val byIdentity = linkedMapOf() + servers.map(::normalize).forEach { server -> + val existingKey = byIdentity.entries.firstOrNull { (_, existing) -> + existing.id == server.id || existing.endpointKey == server.endpointKey + }?.key + if (existingKey == null) { + byIdentity[server.id] = server + } else { + byIdentity[existingKey] = mergeServer(byIdentity.getValue(existingKey), server) + } + } + return byIdentity.values.toList() + } + + private fun mergeServer(primary: SavedServer, fallback: SavedServer): SavedServer = primary.copy( + displayName = primary.displayName.takeIf { it.isNotBlank() } ?: fallback.displayName, + username = primary.username ?: fallback.username, + allowInsecure = primary.allowInsecure || fallback.allowInsecure, + pinned = primary.pinned || fallback.pinned, + defaultWorkspace = primary.defaultWorkspace ?: fallback.defaultWorkspace, + lastConnectedAt = listOfNotNull(primary.lastConnectedAt, fallback.lastConnectedAt).maxOrNull(), + ) +} + @Serializable data class PersistedTabState( val version: Int = CURRENT_VERSION, @@ -682,10 +881,11 @@ data class PersistedTab( val sessionId: String? = null, val sessionTitle: String? = null, val workspaceKey: PersistedWorkspaceKey? = null, + val serverEndpointKey: String? = null, ) { fun resolvedWorkspaceKey(): WorkspaceKey? = workspaceKey?.toWorkspaceKey() + fun resolvedServerEndpointKey(fallback: String? = null): String? = serverEndpointKey ?: fallback } - @Serializable data class PersistedWorkspaceKey( val type: Type, diff --git a/app/src/main/java/dev/blazelight/p4oc/core/network/ServerConnectionRegistry.kt b/app/src/main/java/dev/blazelight/p4oc/core/network/ServerConnectionRegistry.kt new file mode 100644 index 00000000..9b86ac44 --- /dev/null +++ b/app/src/main/java/dev/blazelight/p4oc/core/network/ServerConnectionRegistry.kt @@ -0,0 +1,89 @@ +package dev.blazelight.p4oc.core.network + +import dev.blazelight.p4oc.core.datastore.SavedServer +import dev.blazelight.p4oc.core.datastore.SettingsDataStore +import dev.blazelight.p4oc.domain.server.ServerRef +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap + +/** + * Owns connection state per saved server so multi-server tabs do not depend on a + * mutable app-global current server. The existing [ConnectionManager] remains the + * single-server implementation for the active legacy flow; this registry is the + * multi-server coordination surface used by the pinned Home architecture. + */ +class ServerConnectionRegistry constructor( + private val settingsDataStore: SettingsDataStore, + private val connectionManagerFactory: (ServerConfig) -> ConnectionManager, + private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO), +) { + private val states = ConcurrentHashMap>() + private val managers = ConcurrentHashMap() + private val connections = ConcurrentHashMap>() + + fun connectionState(serverRef: ServerRef): StateFlow = stateFlow(serverRef.endpointKey).asStateFlow() + + fun connection(serverRef: ServerRef): StateFlow = connections.getOrPut(serverRef.endpointKey) { + MutableStateFlow(null).asStateFlow() + } + + fun api(serverRef: ServerRef): OpenCodeApi? = managers[serverRef.endpointKey]?.getApi() + + fun connect(server: SavedServer, password: String? = null) { + val serverRef = server.toServerRef() + val state = stateFlow(server.endpointKey) + state.value = ConnectionState.Connecting + val manager = managers.getOrPut(server.endpointKey) { + connectionManagerFactory(server.toServerConfig()) + } + connections[server.endpointKey] = manager.connection + scope.launch { + val result = manager.connect(server.toServerConfig(), password) + state.value = result.fold( + onSuccess = { manager.connectionState.value }, + onFailure = { ConnectionState.Error(it.message ?: "Connection failed") }, + ) + if (state.value is ConnectionState.Connecting) { + state.value = ConnectionState.Connected + } + } + } + + suspend fun connect(serverId: String, password: String? = null) { + val server = settingsDataStore.findSavedServer(serverId) ?: return + connect(server, password ?: settingsDataStore.getSavedServerPassword(server)) + } + + fun disconnect(serverRef: ServerRef) { + managers.remove(serverRef.endpointKey)?.disconnect() + connections.remove(serverRef.endpointKey) + stateFlow(serverRef.endpointKey).value = ConnectionState.Disconnected + } + + suspend fun reconnectAll(openTabServers: Set) { + val savedByKey = settingsDataStore.getSavedServers().associateBy { it.endpointKey } + openTabServers.forEach { serverRef -> + val saved = savedByKey[serverRef.endpointKey] ?: return@forEach + connect(saved, settingsDataStore.getSavedServerPassword(saved)) + } + } + + private fun stateFlow(endpointKey: String): MutableStateFlow = + states.getOrPut(endpointKey) { MutableStateFlow(ConnectionState.Disconnected) } +} + +fun SavedServer.toServerRef(): ServerRef = ServerRef.fromEndpointKey(endpointKey, displayName) + +fun SavedServer.toServerConfig(): ServerConfig = ServerConfig( + url = endpoint, + name = displayName, + isLocal = endpoint.contains("localhost") || endpoint.contains("127.0.0.1"), + username = username, + allowInsecure = allowInsecure, +) diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/attention/AttentionBadgeRegistry.kt b/app/src/main/java/dev/blazelight/p4oc/ui/attention/AttentionBadgeRegistry.kt new file mode 100644 index 00000000..61191de2 --- /dev/null +++ b/app/src/main/java/dev/blazelight/p4oc/ui/attention/AttentionBadgeRegistry.kt @@ -0,0 +1,53 @@ +package dev.blazelight.p4oc.ui.attention + +import dev.blazelight.p4oc.domain.server.ServerRef +import dev.blazelight.p4oc.domain.server.WorkspaceKey +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +data class AttentionKey( + val serverRef: ServerRef, + val workspaceKey: WorkspaceKey? = null, + val tabId: String? = null, +) + +enum class AttentionSeverity { Info, Warning, Error } + +data class AttentionSignal( + val key: AttentionKey, + val severity: AttentionSeverity, + val reason: String, +) + +data class AttentionBadgeState( + val signals: List = emptyList(), +) { + val homeCount: Int get() = signals.size + fun forServer(serverRef: ServerRef): List = signals.filter { it.key.serverRef == serverRef } + fun forWorkspace(serverRef: ServerRef, workspaceKey: WorkspaceKey): List = + signals.filter { it.key.serverRef == serverRef && it.key.workspaceKey == workspaceKey } + fun forTab(tabId: String): List = signals.filter { it.key.tabId == tabId } +} + +class AttentionBadgeRegistry { + private val _state = MutableStateFlow(AttentionBadgeState()) + val state: StateFlow = _state.asStateFlow() + + fun set(signal: AttentionSignal) { + _state.update { current -> + current.copy( + signals = current.signals.filterNot { it.key == signal.key && it.reason == signal.reason } + signal, + ) + } + } + + fun clearTab(tabId: String) { + _state.update { current -> current.copy(signals = current.signals.filterNot { it.key.tabId == tabId }) } + } + + fun clearServer(serverRef: ServerRef) { + _state.update { current -> current.copy(signals = current.signals.filterNot { it.key.serverRef == serverRef }) } + } +} diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/navigation/Screen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/navigation/Screen.kt index fdb6e24a..68d2541d 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/navigation/Screen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/navigation/Screen.kt @@ -5,6 +5,7 @@ import android.net.Uri sealed class Screen(val route: String) { data object Setup : Screen("setup") data object Server : Screen("server") + data object Home : Screen("home") data object Sessions : Screen("sessions") data object Chat : Screen("chat/{sessionId}") { diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/home/HomeScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/home/HomeScreen.kt new file mode 100644 index 00000000..16f5bf28 --- /dev/null +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/home/HomeScreen.kt @@ -0,0 +1,261 @@ +package dev.blazelight.p4oc.ui.screens.home + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Terminal +import androidx.compose.material.icons.filled.ViewList +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.platform.testTag +import dev.blazelight.p4oc.ui.theme.LocalOpenCodeTheme +import dev.blazelight.p4oc.domain.server.WorkspaceKey +import dev.blazelight.p4oc.ui.theme.Spacing +import dev.blazelight.p4oc.ui.theme.TuiShapes + +@Composable +fun HomeScreen( + summary: HomeSummaryState, + onBrowseSessions: () -> Unit, + onOpenFiles: () -> Unit, + onOpenTerminal: () -> Unit, + modifier: Modifier = Modifier, + onWorkspaceSelected: (WorkspaceSummary) -> Unit = {}, +) { + val theme = LocalOpenCodeTheme.current + + var selectedWorkspace by remember { mutableStateOf(null) } + val selected = selectedWorkspace + if (selected != null) { + WorkspaceDetail( + workspace = selected, + openWork = summary.openWork.filter { + it.serverRef == selected.serverRef && it.workspaceKey == selected.workspaceKey + }, + onBack = { selectedWorkspace = null }, + onOpenFiles = onOpenFiles, + onOpenTerminal = onOpenTerminal, + modifier = modifier, + ) + return + } + Column( + modifier = modifier + .fillMaxSize() + .padding(Spacing.md) + .testTag("home_screen"), + verticalArrangement = Arrangement.spacedBy(Spacing.md), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(Spacing.sm), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Home, + contentDescription = null, + tint = theme.text, + ) + Column(verticalArrangement = Arrangement.spacedBy(Spacing.xxs)) { + Text( + text = "Home", + style = MaterialTheme.typography.titleMedium, + color = theme.text, + ) + Text( + text = "Open existing work, resume sessions, and browse workspaces.", + style = MaterialTheme.typography.bodySmall, + color = theme.textMuted, + ) + } + } + + HomeSection( + title = "Open work", + body = "${summary.openWork.size} bounded open work item${if (summary.openWork.size == 1) "" else "s"}; ${summary.workspaces.size} workspace summar${if (summary.workspaces.size == 1) "y" else "ies"} loaded without chat history.", + ) + + HomeSection( + title = "Servers", + body = summary.servers.joinToString { "${it.displayName}: ${it.openTabCount} tabs" } + .ifBlank { "No saved servers yet." }, + ) + + summary.workspaces.take(6).forEach { workspace -> + HomeActionRow( + label = workspace.workspaceKey.displayLabel(), + description = "${workspace.serverRef.displayName} · ${workspace.openTabCount} open item${if (workspace.openTabCount == 1) "" else "s"}", + icon = { Icon(Icons.Default.Folder, contentDescription = null, tint = theme.textMuted) }, + onClick = { + selectedWorkspace = workspace + onWorkspaceSelected(workspace) + }, + testTag = "home_workspace_${workspace.serverRef.endpointKey}_${workspace.workspaceKey.displayLabel()}", + ) + } + + HomeActionRow( + label = "Browse sessions", + description = "Search, resume, rename, share, summarize, delete, or view changes.", + icon = { Icon(Icons.Default.ViewList, contentDescription = null, tint = theme.textMuted) }, + onClick = onBrowseSessions, + testTag = "home_browse_sessions", + ) + + HomeActionRow( + label = "Open files", + description = "Open the current workspace files tab.", + icon = { Icon(Icons.Default.Folder, contentDescription = null, tint = theme.textMuted) }, + onClick = onOpenFiles, + testTag = "home_open_files", + ) + + HomeActionRow( + label = "Open terminal", + description = "Create a terminal in the current workspace context.", + icon = { Icon(Icons.Default.Terminal, contentDescription = null, tint = theme.textMuted) }, + onClick = onOpenTerminal, + testTag = "home_open_terminal", + ) + + HomeSection( + title = "Attention", + body = "Attention appears as compact badges and dots on Home, tabs, servers, and workspaces — not as a feed.", + ) + } +} + +@Composable +private fun HomeSection(title: String, body: String) { + val theme = LocalOpenCodeTheme.current + Surface( + modifier = Modifier.fillMaxWidth(), + shape = TuiShapes.medium, + color = theme.backgroundElement, + ) { + Column( + modifier = Modifier.padding(Spacing.sm), + verticalArrangement = Arrangement.spacedBy(Spacing.xxs), + ) { + Text(title, style = MaterialTheme.typography.labelMedium, color = theme.text) + Text(body, style = MaterialTheme.typography.bodySmall, color = theme.textMuted) + } + } +} + +@Composable +private fun HomeActionRow( + label: String, + description: String, + icon: @Composable () -> Unit, + onClick: () -> Unit, + testTag: String, + modifier: Modifier = Modifier, +) { + val theme = LocalOpenCodeTheme.current + Surface( + onClick = onClick, + modifier = modifier.fillMaxWidth().testTag(testTag), + shape = RectangleShape, + color = theme.background, + ) { + Row( + modifier = Modifier.padding(Spacing.sm), + horizontalArrangement = Arrangement.spacedBy(Spacing.sm), + verticalAlignment = Alignment.CenterVertically, + ) { + icon() + Column(verticalArrangement = Arrangement.spacedBy(Spacing.xxs)) { + Text(label, style = MaterialTheme.typography.labelMedium, color = theme.text) + Text(description, style = MaterialTheme.typography.bodySmall, color = theme.textMuted) + } + } + } +} + +@Composable +private fun WorkspaceDetail( + workspace: WorkspaceSummary, + openWork: List, + onBack: () -> Unit, + onOpenFiles: () -> Unit, + onOpenTerminal: () -> Unit, + modifier: Modifier = Modifier, +) { + val theme = LocalOpenCodeTheme.current + Column( + modifier = modifier + .fillMaxSize() + .padding(Spacing.md) + .testTag("home_workspace_detail"), + verticalArrangement = Arrangement.spacedBy(Spacing.md), + ) { + HomeActionRow( + label = "← Home", + description = "Back to all workspaces", + icon = { Icon(Icons.Default.Home, contentDescription = null, tint = theme.textMuted) }, + onClick = onBack, + testTag = "home_workspace_detail_back", + ) + HomeSection( + title = workspace.workspaceKey.displayLabel(), + body = "${workspace.serverRef.displayName} · ${workspace.workspaceKey.detailLabel()}", + ) + HomeSection( + title = "Open in this workspace", + body = openWork.joinToString { it.route }.ifBlank { "No open work in this workspace yet." }, + ) + HomeActionRow( + label = "Browse filtered sessions", + description = "Use Sessions search/actions scoped to this workspace.", + icon = { Icon(Icons.Default.ViewList, contentDescription = null, tint = theme.textMuted) }, + onClick = onBack, + testTag = "home_workspace_detail_sessions", + ) + Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { + HomeActionRow( + label = "+ Files", + description = "Focus or create Files for this workspace.", + icon = { Icon(Icons.Default.Folder, contentDescription = null, tint = theme.textMuted) }, + onClick = onOpenFiles, + testTag = "home_workspace_detail_files", + modifier = Modifier.weight(1f), + ) + HomeActionRow( + label = "+ Terminal", + description = "Create Terminal here.", + icon = { Icon(Icons.Default.Terminal, contentDescription = null, tint = theme.textMuted) }, + onClick = onOpenTerminal, + testTag = "home_workspace_detail_terminal", + modifier = Modifier.weight(1f), + ) + } + } +} + +private fun WorkspaceKey.displayLabel(): String = when (this) { + WorkspaceKey.Global -> "Global workspace" + is WorkspaceKey.Directory -> value.trimEnd('/').substringAfterLast('/').ifBlank { value } + is WorkspaceKey.SessionScoped -> "Session ${sessionId.value}" +} + +private fun WorkspaceKey.detailLabel(): String = when (this) { + WorkspaceKey.Global -> "No project context" + is WorkspaceKey.Directory -> value + is WorkspaceKey.SessionScoped -> "Session-scoped workspace" +} diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/home/HomeSummary.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/home/HomeSummary.kt new file mode 100644 index 00000000..a24e2cff --- /dev/null +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/home/HomeSummary.kt @@ -0,0 +1,89 @@ +package dev.blazelight.p4oc.ui.screens.home + +import dev.blazelight.p4oc.core.datastore.SavedServer +import dev.blazelight.p4oc.core.network.ConnectionState +import dev.blazelight.p4oc.domain.server.ServerRef +import dev.blazelight.p4oc.domain.server.WorkspaceKey +import dev.blazelight.p4oc.ui.tabs.TabInstance + +data class ServerSummary( + val serverRef: ServerRef, + val displayName: String, + val connectionState: ConnectionState, + val openTabCount: Int, +) + +data class WorkspaceSummary( + val serverRef: ServerRef, + val workspaceKey: WorkspaceKey, + val openTabCount: Int, +) + +data class OpenWorkSummary( + val tabId: String, + val serverRef: ServerRef, + val workspaceKey: WorkspaceKey, + val route: String, +) + +data class HomeSummaryState( + val servers: List, + val workspaces: List, + val openWork: List, + val partialFailures: List = emptyList(), +) + +object HomeSummaryBuilder { + fun build( + savedServers: List, + connectionStates: Map, + tabs: List, + workspaceLimit: Int = 12, + openWorkLimit: Int = 24, + ): HomeSummaryState { + val workTabs = tabs.filterNot { it.isPinnedHome } + val failures = mutableListOf() + val serverSummaries = savedServers.mapNotNull { saved -> + runCatching { + val serverRef = ServerRef.fromEndpointKey(saved.endpointKey, saved.displayName) + ServerSummary( + serverRef = serverRef, + displayName = saved.displayName, + connectionState = connectionStates[saved.endpointKey] ?: ConnectionState.Disconnected, + openTabCount = workTabs.count { it.serverEndpointKey == saved.endpointKey }, + ) + }.getOrElse { + failures += saved.endpointKey + null + } + } + val openWork = workTabs.mapNotNull { tab -> + val serverRef = tab.serverRef ?: return@mapNotNull null + val workspaceKey = tab.workspaceKey ?: return@mapNotNull null + OpenWorkSummary( + tabId = tab.id, + serverRef = serverRef, + workspaceKey = workspaceKey, + route = tab.startRoute, + ) + }.take(openWorkLimit) + val workspaces = openWork + .groupBy { it.serverRef.endpointKey to it.workspaceKey } + .values + .map { grouped -> + val first = grouped.first() + WorkspaceSummary( + serverRef = first.serverRef, + workspaceKey = first.workspaceKey, + openTabCount = grouped.size, + ) + } + .take(workspaceLimit) + return HomeSummaryState( + servers = serverSummaries, + workspaces = workspaces, + openWork = openWork, + partialFailures = failures, + ) + } +} diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerScreen.kt index a92ce0b4..b4bb92e8 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerScreen.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.blazelight.p4oc.R import dev.blazelight.p4oc.core.datastore.RecentServer +import dev.blazelight.p4oc.core.datastore.SavedServer import dev.blazelight.p4oc.core.network.DiscoveredServer import dev.blazelight.p4oc.core.network.DiscoveryState import dev.blazelight.p4oc.ui.components.TuiLoadingIndicator @@ -120,6 +121,20 @@ fun ServerScreen( ) } + if (uiState.savedServers.isNotEmpty()) { + SavedServersSection( + servers = uiState.savedServers, + isConnecting = uiState.isConnecting, + hasOpenTabs = false, + onServerClick = { saved -> + viewModel.setRemoteUrl(saved.endpoint) + viewModel.setUsername(saved.username ?: "opencode") + viewModel.setAllowInsecure(saved.allowInsecure) + }, + onRemoveServer = viewModel::removeSavedServer, + ) + } + if (uiState.recentServers.isNotEmpty()) { RecentServersSection( servers = uiState.recentServers, @@ -476,6 +491,76 @@ private fun SetupCodeBlock(command: String) { } } +@Composable +private fun SavedServersSection( + servers: List, + isConnecting: Boolean, + hasOpenTabs: Boolean, + onServerClick: (SavedServer) -> Unit, + onRemoveServer: (SavedServer) -> Unit, +) { + val theme = LocalOpenCodeTheme.current + + Surface( + color = theme.backgroundElement, + shape = RectangleShape, + ) { + Column( + modifier = Modifier.padding(Spacing.md), + verticalArrangement = Arrangement.spacedBy(Spacing.xs), + ) { + Text( + text = "[ Saved servers ]", + style = MaterialTheme.typography.titleMedium, + fontFamily = FontFamily.Monospace, + color = theme.text, + ) + Text( + text = "Manage saved connection targets. Remove warns when open tabs reference this server.", + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = theme.textMuted, + ) + servers.forEach { server -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = !isConnecting, role = Role.Button) { onServerClick(server) } + .padding(vertical = Spacing.md), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.lg), + ) { + Text("●", color = theme.success, fontFamily = FontFamily.Monospace) + Column(modifier = Modifier.weight(1f)) { + Text( + text = server.displayName, + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + color = theme.text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "${server.endpoint} · ${if (server.allowInsecure) "TLS checks off" else "TLS checks on"}", + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = theme.textMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Text( + text = if (hasOpenTabs) "warn remove" else "remove", + color = theme.warning, + fontFamily = FontFamily.Monospace, + modifier = Modifier.clickable(role = Role.Button) { onRemoveServer(server) }, + ) + } + } + } + } +} + @Composable private fun RecentServersSection( servers: List, diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerViewModel.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerViewModel.kt index e7f64ed2..8262a94f 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerViewModel.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerViewModel.kt @@ -3,6 +3,7 @@ package dev.blazelight.p4oc.ui.screens.server import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dev.blazelight.p4oc.core.datastore.RecentServer +import dev.blazelight.p4oc.core.datastore.SavedServer import dev.blazelight.p4oc.core.datastore.SettingsDataStore import dev.blazelight.p4oc.core.log.AppLog import dev.blazelight.p4oc.core.network.ConnectionManager @@ -33,6 +34,7 @@ class ServerViewModel constructor( init { loadRecentServers() + loadSavedServers() collectDiscoveryFlows() tryAutoReconnect() } @@ -45,6 +47,14 @@ class ServerViewModel constructor( } } + private fun loadSavedServers() { + viewModelScope.launch { + settingsDataStore.savedServers.collect { servers -> + _uiState.update { it.copy(savedServers = servers) } + } + } + } + private fun tryAutoReconnect() { viewModelScope.launch { val (lastConfig, password) = settingsDataStore.getLastConnection() ?: return@launch @@ -142,6 +152,14 @@ class ServerViewModel constructor( password = password, allowInsecure = state.allowInsecure ) + settingsDataStore.addSavedServer( + url = url, + name = "Remote Server", + username = state.username.takeIf { it.isNotBlank() }, + password = password, + allowInsecure = state.allowInsecure, + lastConnectedAt = System.currentTimeMillis(), + ) initializeProjectContext() _uiState.update { it.copy(isConnecting = false, isConnected = true) } }, @@ -181,6 +199,12 @@ class ServerViewModel constructor( } } + fun removeSavedServer(server: SavedServer) { + viewModelScope.launch { + settingsDataStore.removeSavedServer(server.id) + } + } + private fun collectDiscoveryFlows() { viewModelScope.launch { mdnsDiscoveryManager.discoveredServers.collect { servers -> @@ -246,6 +270,7 @@ data class ServerUiState( val isConnected: Boolean = false, val error: String? = null, val recentServers: List = emptyList(), + val savedServers: List = emptyList(), val discoveredServers: List = emptyList(), val discoveryState: DiscoveryState = DiscoveryState.IDLE, // Navigation destination after connection diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/MainTabScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/MainTabScreen.kt index ebbc1d8b..d29d1ab9 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/MainTabScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/MainTabScreen.kt @@ -6,8 +6,6 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* @@ -16,7 +14,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalLifecycleOwner -import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.style.TextOverflow import androidx.lifecycle.Lifecycle @@ -71,9 +68,17 @@ fun MainTabScreen( val activeTabId by tabManager.activeTabId.collectAsState() val showTabWarning by tabManager.showTabWarning.collectAsState() val connectionState by connectionManager.connectionState.collectAsState() + val currentServerRef = remember(connectionManager.currentBaseUrl) { + connectionManager.currentBaseUrl?.let { ServerRef.fromEndpoint(it) } + } var wasEverConnected by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + tabManager.ensureHomeTab(focus = false) + } var restoreError by remember { mutableStateOf(null) } + var showStartWorkSheet by remember { mutableStateOf(false) } var showFilesTabPrompt by remember { mutableStateOf(false) } // Foreground resume is delegated to ConnectionManager so reconnect policy @@ -112,12 +117,13 @@ fun MainTabScreen( is RestoreResult.ServerMismatch -> { restoreError = "Saved tabs belong to ${result.persistedEndpointKey}, not ${result.activeEndpointKey}. Starting fresh." } + is RestoreResult.MissingServer -> { + restoreError = "Saved tabs reference unavailable server ${result.endpointKey}. Starting fresh." + } } } - if (!tabManager.hasTabs()) { - val initialTab = TabInstance(TabState(workspaceKey = WorkspaceKey.Global)) - tabManager.registerTab(initialTab, focus = true) + tabManager.ensureHomeTab(focus = true) } } } @@ -279,6 +285,14 @@ fun MainTabScreen( showFilesTabPrompt = true } + fun requestFilesTab(workspaceKey: WorkspaceKey) { + val targetServer = tabManager.activeTab?.serverRef ?: currentServerRef ?: return + tabManager.focusOrCreateFilesTab( + serverRef = targetServer, + workspaceKey = workspaceKey, + ) + } + LaunchedEffect(showTabWarning) { if (showTabWarning) { snackbarHostState.showSnackbar( @@ -312,12 +326,8 @@ fun MainTabScreen( .statusBarsPadding() .consumeWindowInsets(WindowInsets.statusBars) ) { - // Tab bar (no longer needs its own statusBarsPadding) - // Wrapped in a Box so the New-tab DropdownMenu can anchor to the top-end, - // which visually aligns it near the + button inside TabBar. - var newTabMenuExpanded by remember { mutableStateOf(false) } - // Top-level plus actions are server/global by default; contextual tab actions inherit below. - val globalWorkspaceKey = WorkspaceKey.Global + // Tab bar (no longer needs its own statusBarsPadding). + // Top-level plus actions inherit the active tab target when possible. Box(modifier = Modifier.fillMaxWidth()) { TabBar( tabs = tabs, @@ -330,91 +340,9 @@ fun MainTabScreen( }, onTabClose = closeTab, onAddClick = { - newTabMenuExpanded = true + showStartWorkSheet = true }, ) - // Anchor the dropdown to the top-end of the TabBar (where the + button lives). - Box(modifier = Modifier.align(Alignment.TopEnd)) { - DropdownMenu( - expanded = newTabMenuExpanded, - onDismissRequest = { newTabMenuExpanded = false }, - modifier = Modifier.testTag("tab_bar_add_menu") - ) { - DropdownMenuItem( - text = { Text("New Sessions tab") }, - leadingIcon = { - Icon( - imageVector = Icons.AutoMirrored.Filled.List, - contentDescription = null - ) - }, - onClick = { - newTabMenuExpanded = false - tabManager.createTab( - startRoute = Screen.Sessions.route, - workspaceKey = globalWorkspaceKey, - focus = true, - ) - }, - modifier = Modifier.testTag("tab_bar_add_menu_sessions") - ) - DropdownMenuItem( - text = { Text("New Files tab") }, - leadingIcon = { - Icon( - imageVector = Icons.Default.Folder, - contentDescription = null - ) - }, - onClick = { - newTabMenuExpanded = false - requestFilesTab() - }, - modifier = Modifier.testTag("tab_bar_add_menu_files") - ) - DropdownMenuItem( - text = { Text("New Terminal tab") }, - leadingIcon = { - Icon( - imageVector = Icons.Default.Terminal, - contentDescription = null - ) - }, - onClick = { - newTabMenuExpanded = false - // Terminal tabs require a server-side PTY, mirroring the - // existing onNewTerminalTab callback flow further below. - coroutineScope.launch { - val api = connectionManager.getApi() ?: run { - AppLog.e(TAG, "Cannot create terminal: not connected") - snackbarHostState.showSnackbar("Not connected to server") - return@launch - } - val result = safeApiCall { - api.createPtySession(createPtyRequestForWorkspace(globalWorkspaceKey)) - } - when (result) { - is ApiResult.Success -> { - val ptyId = result.data.id - tabManager.createTab( - startRoute = Screen.Terminal.createRoute(ptyId), - workspaceKey = globalWorkspaceKey, - focus = true, - ) - } - is ApiResult.Error -> { - AppLog.e(TAG, "Failed to create PTY: ${result.message}") - snackbarHostState.showSnackbar( - "Failed to create terminal: ${result.message}" - ) - } - } - } - }, - modifier = Modifier.testTag("tab_bar_add_menu_terminal") - ) - } - } } // Pager state for swipe between tabs @@ -483,6 +411,7 @@ fun MainTabScreen( navController = navController, tabManager = tabManager, tabId = tab.id, + serverRef = tab.serverRef ?: currentServerRef ?: return@SaveableStateProvider, onDisconnect = onDisconnect, onCloseTab = { closeTab(tab.id) }, startRoute = tab.startRoute, @@ -513,6 +442,7 @@ fun MainTabScreen( tabManager.createTab( startRoute = Screen.Terminal.createRoute(ptyId), workspaceKey = workspaceKey, + serverRef = tab.serverRef ?: currentServerRef ?: return@launch, focus = true, ) } @@ -548,15 +478,125 @@ fun MainTabScreen( } } + if (showStartWorkSheet) { + val startContext = startWorkContextFor(tabManager.activeTab) + val targetWorkspace = startContext.defaultWorkspace ?: WorkspaceKey.Global + val targetServer = startContext.defaultServer ?: currentServerRef + TuiAlertDialog( + onDismissRequest = { showStartWorkSheet = false }, + title = "Start work", + confirmButton = { + TuiTextButton(onClick = { showStartWorkSheet = false }) { + Text("Cancel") + } + }, + ) { + Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm)) { + Text( + text = if (startContext.hasExplicitTarget) { + "Target: ${workspaceLabel(targetWorkspace, tabTitleLabels) ?: workspaceSubtitle(targetWorkspace)}" + } else { + "Choose an action. Current server/workspace will be explicit before creation." + }, + color = theme.textMuted, + style = MaterialTheme.typography.bodySmall, + ) + StartWorkActionRow( + label = "New chat", + description = "Start a chat in the target workspace.", + marker = "C", + onClick = { + showStartWorkSheet = false + val serverRef = targetServer + if (serverRef != null) { + tabManager.createTab( + startRoute = Screen.Sessions.route, + workspaceKey = targetWorkspace, + serverRef = serverRef, + focus = true, + ) + } else { + showFilesTabPrompt = true + } + }, + ) + StartWorkActionRow( + label = "Browse sessions", + description = "Open existing sessions in Home's current context.", + marker = "S", + onClick = { + showStartWorkSheet = false + tabManager.ensureHomeTab(focus = true) + }, + ) + StartWorkActionRow( + label = "Files tab", + description = "Open files for the target workspace.", + marker = "F", + onClick = { + showStartWorkSheet = false + if (targetServer != null) { + tabManager.focusOrCreateFilesTab( + serverRef = targetServer, + workspaceKey = targetWorkspace, + ) + } else { + showFilesTabPrompt = true + } + }, + ) + StartWorkActionRow( + label = "Terminal", + description = "Create a terminal for the target workspace.", + marker = "T", + onClick = { + showStartWorkSheet = false + coroutineScope.launch { + val api = connectionManager.getApi() ?: run { + snackbarHostState.showSnackbar("Not connected to server") + return@launch + } + val serverRef = targetServer ?: run { + snackbarHostState.showSnackbar("No server target selected") + return@launch + } + val result = safeApiCall { + api.createPtySession(createPtyRequestForWorkspace(targetWorkspace)) + } + if (result is ApiResult.Success) { + tabManager.createTab( + startRoute = Screen.Terminal.createRoute(result.data.id), + workspaceKey = targetWorkspace, + serverRef = serverRef, + focus = true, + ) + } else if (result is ApiResult.Error) { + snackbarHostState.showSnackbar("Failed to create terminal: ${result.message}") + } + } + }, + ) + StartWorkActionRow( + label = "Choose another target", + description = "Pick a different workspace target before creating work.", + marker = "…", + onClick = { + showStartWorkSheet = false + showFilesTabPrompt = true + }, + ) + } + } + } + if (showFilesTabPrompt) { val openWorkspaceKeys = tabs .mapNotNull { it.workspaceKey } .distinct() fun openFilesTab(workspaceKey: WorkspaceKey) { - tabManager.createTab( - startRoute = Screen.Files.route, + tabManager.focusOrCreateFilesTab( + serverRef = currentServerRef ?: return, workspaceKey = workspaceKey, - focus = true, ) showFilesTabPrompt = false } @@ -605,6 +645,21 @@ private fun workspaceSubtitle(workspaceKey: WorkspaceKey): String = when (worksp is WorkspaceKey.SessionScoped -> "Session-scoped workspace" } +@Composable +private fun StartWorkActionRow( + label: String, + description: String, + marker: String, + onClick: () -> Unit, +) { + FilesWorkspaceOption( + title = label, + subtitle = description, + marker = marker, + onClick = onClick, + ) +} + @Composable private fun FilesWorkspaceOption( title: String, diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/StartWorkContext.kt b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/StartWorkContext.kt new file mode 100644 index 00000000..6d72c2f1 --- /dev/null +++ b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/StartWorkContext.kt @@ -0,0 +1,53 @@ +package dev.blazelight.p4oc.ui.tabs + +import dev.blazelight.p4oc.domain.server.ServerRef +import dev.blazelight.p4oc.domain.server.WorkspaceKey + +data class StartWorkContext( + val source: StartWorkSource, + val defaultServer: ServerRef?, + val defaultWorkspace: WorkspaceKey?, + val defaultAction: StartWorkAction? = null, +) { + val hasExplicitTarget: Boolean get() = defaultServer != null && defaultWorkspace != null +} + +enum class StartWorkSource { + HomeTopLevel, + HomeWorkspaceDetail, + ChatTab, + FilesTab, + TerminalTab, + OtherTab, +} + +enum class StartWorkAction { + NewChat, + Files, + Terminal, + BrowseSessions, + ChooseAnotherTarget, +} + +fun startWorkContextFor(tab: TabInstance?): StartWorkContext { + if (tab == null || tab.isPinnedHome) { + return StartWorkContext( + source = StartWorkSource.HomeTopLevel, + defaultServer = null, + defaultWorkspace = null, + defaultAction = StartWorkAction.ChooseAnotherTarget, + ) + } + return StartWorkContext( + source = sourceForRoute(tab.startRoute), + defaultServer = tab.serverRef, + defaultWorkspace = tab.workspaceKey, + ) +} + +private fun sourceForRoute(route: String): StartWorkSource = when { + route.startsWith("chat/") -> StartWorkSource.ChatTab + route.startsWith("files") -> StartWorkSource.FilesTab + route.startsWith("terminal/") -> StartWorkSource.TerminalTab + else -> StartWorkSource.OtherTab +} diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabBar.kt b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabBar.kt index 0d6e862d..30bbf720 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabBar.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabBar.kt @@ -87,6 +87,7 @@ fun TabBar( icon = icon, connectionState = connectionState, isActive = isActive, + closeable = !tab.isPinnedHome, onClick = { onTabClick(tab.id) }, onClose = { onTabClose(tab.id) } ) @@ -118,6 +119,7 @@ private fun TabIndicator( icon: ImageVector, connectionState: SessionConnectionState?, isActive: Boolean, + closeable: Boolean = true, onClick: () -> Unit, onClose: () -> Unit, modifier: Modifier = Modifier @@ -172,8 +174,8 @@ private fun TabIndicator( modifier = Modifier.widthIn(max = Sizing.panelWidthSm) ) - // Close button only shows on the active tab. - if (isActive) { + // Close button only shows on the active closeable tab. + if (isActive && closeable) { Icon( imageVector = Icons.Default.Close, contentDescription = "Close tab", diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabManager.kt b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabManager.kt index 70282081..5f609d92 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabManager.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabManager.kt @@ -53,21 +53,28 @@ class TabManager { fun createTab( startRoute: String = "sessions", workspaceKey: WorkspaceKey, + serverRef: ServerRef, focus: Boolean = true ): TabInstance = addTab( tab = TabInstance( - TabState(workspaceKey = workspaceKey), + TabState(workspaceKey = workspaceKey, serverRef = serverRef), startRoute = startRoute, ), focus = focus, ) private fun addTab(tab: TabInstance, focus: Boolean): TabInstance { + val tabToAdd = if (tab.isPinnedHome) TabInstance.home() else tab _tabs.update { currentTabs -> - val newTabs = currentTabs + tab + val withoutDuplicateHome = if (tabToAdd.isPinnedHome) { + currentTabs.filterNot { it.isPinnedHome } + } else { + currentTabs + } + val newTabs = ensureHomeFirst(withoutDuplicateHome + tabToAdd) - // Show warning at 5+ tabs (once per session) - if (newTabs.size >= 5 && !tabWarningShown) { + // Show warning at 5+ closeable work tabs (once per session) + if (newTabs.count { !it.isPinnedHome } >= 5 && !tabWarningShown) { _showTabWarning.value = true tabWarningShown = true } @@ -76,10 +83,12 @@ class TabManager { } if (focus) { - _activeTabId.value = tab.id + _activeTabId.value = tabToAdd.id + } else if (_activeTabId.value == null) { + _activeTabId.value = TabInstance.HOME_TAB_ID } - return tab + return tabToAdd } /** @@ -92,25 +101,18 @@ class TabManager { val tabIndex = currentTabs.indexOfFirst { it.id == tabId } if (tabIndex == -1) return + if (currentTabs[tabIndex].isPinnedHome) return val isActive = _activeTabId.value == tabId - if (currentTabs.size == 1) { - // Last tab - create a fresh replacement - val newTab = TabInstance(TabState(workspaceKey = WorkspaceKey.Global)) - _tabs.value = listOf(newTab) - _activeTabId.value = newTab.id - return - } - // Remove the tab - _tabs.update { tabs -> tabs.filter { it.id != tabId } } + _tabs.update { tabs -> ensureHomeFirst(tabs.filter { it.id != tabId }) } - // If was active, focus adjacent + // If was active, focus adjacent closeable tab or pinned Home. if (isActive) { val newTabs = _tabs.value val newActiveIndex = minOf(tabIndex, newTabs.size - 1) - _activeTabId.value = newTabs.getOrNull(newActiveIndex)?.id + _activeTabId.value = newTabs.getOrNull(newActiveIndex)?.id ?: TabInstance.HOME_TAB_ID } } @@ -126,6 +128,36 @@ class TabManager { /** * Find a tab that's showing the given session. */ + + fun findFilesTab(serverRef: ServerRef, workspaceKey: WorkspaceKey): TabInstance? = + _tabs.value.firstOrNull { + !it.isPinnedHome && + it.serverRef == serverRef && + it.workspaceKey == workspaceKey && + it.startRoute == Screen.Files.route + } + + fun focusOrCreateFilesTab(serverRef: ServerRef, workspaceKey: WorkspaceKey): TabInstance { + val existing = findFilesTab(serverRef, workspaceKey) + if (existing != null) { + focusTab(existing.id) + return existing + } + return createTab( + startRoute = Screen.Files.route, + workspaceKey = workspaceKey, + serverRef = serverRef, + focus = true, + ) + } + + fun findTerminalTabs(serverRef: ServerRef, workspaceKey: WorkspaceKey): List = + _tabs.value.filter { + !it.isPinnedHome && + it.serverRef == serverRef && + it.workspaceKey == workspaceKey && + it.startRoute.startsWith("terminal/") + } fun findTabBySessionId(sessionId: String): TabInstance? { return _tabs.value.find { it.sessionId == sessionId } } @@ -167,35 +199,47 @@ class TabManager { fun saveState(serverRef: ServerRef): PersistedTabState? { val currentTabs = _tabs.value - if (currentTabs.isEmpty()) return null return PersistedTabState( serverEndpointKey = serverRef.endpointKey, activeTabId = _activeTabId.value, - tabs = currentTabs.map { tab -> + tabs = currentTabs.filterNot { it.isPinnedHome }.map { tab -> PersistedTab( id = tab.id, startRoute = persistableStartRoute(tab), sessionId = tab.sessionId, sessionTitle = tab.sessionTitle, workspaceKey = tab.workspaceKey?.let(PersistedWorkspaceKey::fromWorkspaceKey), + serverEndpointKey = tab.serverEndpointKey ?: serverRef.endpointKey, ) }, ) } fun restoreState(state: PersistedTabState, activeServer: ServerRef): RestoreResult { + return restoreState(state, mapOf(activeServer.endpointKey to activeServer), fallbackServer = activeServer) + } + + fun restoreState( + state: PersistedTabState, + availableServers: Map, + fallbackServer: ServerRef? = null, + ): RestoreResult { if (state.version != PersistedTabState.CURRENT_VERSION) { restored = true return RestoreResult.VersionMismatch(state.version) } - if (state.serverEndpointKey != activeServer.endpointKey) { - restored = true - return RestoreResult.ServerMismatch(state.serverEndpointKey, activeServer.endpointKey) - } + val missingServerEndpointKeys = linkedSetOf() val restoredTabs = state.tabs.mapNotNull { persisted -> if (persisted.id.isBlank()) return@mapNotNull null val workspaceKey = persisted.resolvedWorkspaceKey() ?: return@mapNotNull null + val serverEndpointKey = persisted.resolvedServerEndpointKey(state.serverEndpointKey) ?: return@mapNotNull null + val serverRef = availableServers[serverEndpointKey] ?: fallbackServer?.takeIf { + it.endpointKey == serverEndpointKey + } ?: run { + missingServerEndpointKeys += serverEndpointKey + return@mapNotNull null + } val route = persisted.sessionId?.let { TabChatRouteCodec.chatRoute(it) } ?: persisted.startRoute.takeIf { it.isNotBlank() } ?: Screen.Sessions.route @@ -205,21 +249,26 @@ class TabManager { sessionId = persisted.sessionId, sessionTitle = persisted.sessionTitle, workspaceKey = workspaceKey, + serverRef = serverRef, ), startRoute = route, ) } + val withHome = ensureHomeFirst(restoredTabs) if (restoredTabs.isEmpty()) { + _tabs.value = withHome + _activeTabId.value = TabInstance.HOME_TAB_ID restored = true - return RestoreResult.Empty + return missingServerEndpointKeys.firstOrNull()?.let { RestoreResult.MissingServer(it) } ?: RestoreResult.Empty } - _tabs.value = restoredTabs - _activeTabId.value = state.activeTabId?.takeIf { activeId -> restoredTabs.any { it.id == activeId } } + _tabs.value = withHome + _activeTabId.value = state.activeTabId?.takeIf { activeId -> withHome.any { it.id == activeId } } ?: restoredTabs.first().id restored = true - return RestoreResult.Restored(restoredTabs.size) + return missingServerEndpointKeys.firstOrNull()?.let { RestoreResult.MissingServer(it, restoredTabs.size) } + ?: RestoreResult.Restored(restoredTabs.size) } fun shouldAttemptRestore(): Boolean = !restored && _tabs.value.isEmpty() @@ -236,13 +285,14 @@ class TabManager { */ fun registerTab(tab: TabInstance, focus: Boolean = true) { _tabs.update { currentTabs -> - if (currentTabs.any { it.id == tab.id }) { - currentTabs // Already registered + val registered = if (tab.isPinnedHome) TabInstance.home() else tab + if (currentTabs.any { it.id == registered.id }) { + ensureHomeFirst(currentTabs) } else { - val newTabs = currentTabs + tab + val newTabs = ensureHomeFirst(currentTabs + registered) - // Show warning at 5+ tabs (once per session) - if (newTabs.size >= 5 && !tabWarningShown) { + // Show warning at 5+ closeable work tabs (once per session) + if (newTabs.count { !it.isPinnedHome } >= 5 && !tabWarningShown) { _showTabWarning.value = true tabWarningShown = true } @@ -252,7 +302,9 @@ class TabManager { } if (focus) { - _activeTabId.value = tab.id + _activeTabId.value = if (tab.isPinnedHome) TabInstance.HOME_TAB_ID else tab.id + } else if (_activeTabId.value == null) { + _activeTabId.value = TabInstance.HOME_TAB_ID } } @@ -266,6 +318,21 @@ class TabManager { */ fun tabCount(): Int = _tabs.value.size + fun ensureHomeTab(focus: Boolean = false): TabInstance { + val existing = _tabs.value.firstOrNull { it.isPinnedHome } + if (existing != null) { + _tabs.update(::ensureHomeFirst) + if (focus) _activeTabId.value = existing.id + return existing + } + return addTab(TabInstance.home(), focus = focus) + } + + private fun ensureHomeFirst(tabs: List): List { + val home = tabs.firstOrNull { it.isPinnedHome } ?: TabInstance.home() + return listOf(home) + tabs.filterNot { it.isPinnedHome } + } + private fun persistableStartRoute(tab: TabInstance): String = when (val sessionId = tab.sessionId) { null -> if (tab.startRoute.startsWith("terminal/")) Screen.Sessions.route else tab.startRoute else -> TabChatRouteCodec.chatRoute(sessionId) @@ -277,4 +344,5 @@ sealed interface RestoreResult { data object Empty : RestoreResult data class VersionMismatch(val version: Int) : RestoreResult data class ServerMismatch(val persistedEndpointKey: String, val activeEndpointKey: String) : RestoreResult + data class MissingServer(val endpointKey: String, val restoredCount: Int = 0) : RestoreResult } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabNavHost.kt b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabNavHost.kt index 7b60a106..9714bf5a 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabNavHost.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabNavHost.kt @@ -25,6 +25,7 @@ import dev.blazelight.p4oc.core.datastore.SettingsDataStore import dev.blazelight.p4oc.core.datastore.VisualSettings import dev.blazelight.p4oc.domain.model.SessionConnectionState import dev.blazelight.p4oc.domain.server.WorkspaceKey +import dev.blazelight.p4oc.domain.server.ServerRef import dev.blazelight.p4oc.ui.navigation.Screen import dev.blazelight.p4oc.ui.screens.chat.ChatScreen import dev.blazelight.p4oc.ui.screens.diff.DiffViewerScreen @@ -32,6 +33,8 @@ import dev.blazelight.p4oc.ui.screens.diff.SessionDiffScreen import dev.blazelight.p4oc.ui.screens.files.FileExplorerScreen import dev.blazelight.p4oc.ui.screens.files.FileViewerScreen import dev.blazelight.p4oc.ui.screens.files.FilesViewModel +import dev.blazelight.p4oc.ui.screens.home.HomeScreen +import dev.blazelight.p4oc.ui.screens.home.HomeSummaryBuilder import dev.blazelight.p4oc.ui.screens.projects.ProjectsScreen import dev.blazelight.p4oc.ui.screens.sessions.SessionListScreen import dev.blazelight.p4oc.ui.screens.sessions.SessionListViewModel @@ -73,6 +76,7 @@ fun TabNavHost( isActiveTab: Boolean = true, startRoute: String = Screen.Sessions.route, workspaceOwner: WorkspaceRepositoryOwner, + serverRef: ServerRef, onConnectionStateChanged: ((SessionConnectionState?) -> Unit)? = null, modifier: androidx.compose.ui.Modifier = androidx.compose.ui.Modifier ) { @@ -154,6 +158,19 @@ fun TabNavHost( navArgument(WORKSPACE_ROUTE_ARG_REVISION) { type = NavType.IntType }, ) ) { + composable(Screen.Home.route) { + HomeScreen( + summary = HomeSummaryBuilder.build( + savedServers = emptyList(), + connectionStates = emptyMap(), + tabs = tabs, + ), + onBrowseSessions = { navController.navigate(Screen.Sessions.route) }, + onOpenFiles = onNewFilesTab, + onOpenTerminal = onNewTerminalTab, + ) + } + // Sessions list (start destination for new tabs) composable(Screen.Sessions.route) { backStackEntry -> val workspaceViewModel = TouchWorkspaceViewModel( @@ -344,6 +361,7 @@ fun TabNavHost( tabManager.createTab( startRoute = Screen.Chat.createRoute(subSessionId), workspaceKey = workspaceOwner.workspace.key, + serverRef = serverRef, focus = true, ) } else { diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabState.kt b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabState.kt index efc6a3c3..c71bfd73 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabState.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabState.kt @@ -1,6 +1,8 @@ package dev.blazelight.p4oc.ui.tabs import dev.blazelight.p4oc.domain.model.SessionConnectionState +import dev.blazelight.p4oc.domain.server.ServerRef +import dev.blazelight.p4oc.ui.navigation.Screen import dev.blazelight.p4oc.domain.server.WorkspaceKey import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -34,6 +36,12 @@ data class TabState( /** Workspace key owned by this tab. Null is reserved for legacy recovery. */ val workspaceKey: WorkspaceKey? = null, + /** Server endpoint owned by this tab. Null is allowed only for explicitly global surfaces. */ + val serverRef: ServerRef? = null, + + + /** Pinned Home is global, leftmost, non-closeable, and not a work tab. */ + val pinnedHome: Boolean = false, /** Incremented when workspace changes so navigation graph scoped ViewModels are recreated. */ val workspaceRevision: Int = 0, ) @@ -43,6 +51,7 @@ data class TabState( * page composition scope, not stored here (to avoid ViewModelStore lifecycle crashes). */ class TabInstance( + val state: TabState, /** Declarative start route for this tab's NavHost. Defaults to Sessions list. */ val startRoute: String = "sessions" @@ -51,11 +60,14 @@ class TabInstance( val sessionId: String? get() = state.sessionId val sessionTitle: String? get() = state.sessionTitle val workspaceKey: WorkspaceKey? get() = state.workspaceKey + val serverRef: ServerRef? get() = state.serverRef + val serverEndpointKey: String? get() = state.serverRef?.endpointKey val workspaceDirectory: String? get() = (state.workspaceKey as? WorkspaceKey.Directory)?.value val workspaceRevision: Int get() = state.workspaceRevision /** Connection state for this tab (only relevant for chat tabs) */ private val _connectionState = MutableStateFlow(null) + val isPinnedHome: Boolean get() = state.pinnedHome val connectionState: StateFlow = _connectionState.asStateFlow() /** Update the connection state for this tab */ @@ -73,6 +85,10 @@ class TabInstance( return withState(state.copy(sessionId = sessionId, sessionTitle = sessionTitle)) } + fun withServerRef(serverRef: ServerRef?): TabInstance { + return withState(state.copy(serverRef = serverRef)) + } + fun withWorkspaceKey(workspaceKey: WorkspaceKey?): TabInstance { if (workspaceKey == state.workspaceKey) return this return withState( @@ -84,4 +100,12 @@ class TabInstance( ), ) } + companion object { + const val HOME_TAB_ID = "pinned-home" + + fun home(): TabInstance = TabInstance( + TabState(id = HOME_TAB_ID, pinnedHome = true), + startRoute = Screen.Home.route, + ) + } } diff --git a/app/src/test/java/dev/blazelight/p4oc/core/datastore/SavedServerRegistryTest.kt b/app/src/test/java/dev/blazelight/p4oc/core/datastore/SavedServerRegistryTest.kt new file mode 100644 index 00000000..1eb4e362 --- /dev/null +++ b/app/src/test/java/dev/blazelight/p4oc/core/datastore/SavedServerRegistryTest.kt @@ -0,0 +1,107 @@ +package dev.blazelight.p4oc.core.datastore + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class SavedServerRegistryTest { + + @Test + fun `fromConnection preserves no-port endpoint for display and canonicalizes endpoint key`() { + val server = SavedServerRegistry.fromConnection( + url = "https://my-host.example.com", + name = "Remote", + username = "opencode", + ) + + assertEquals("https://my-host.example.com", server.endpoint) + assertEquals("https://my-host.example.com:4096", server.endpointKey) + assertEquals(server.endpointKey, server.id) + assertEquals("Remote", server.displayName) + assertEquals("opencode", server.username) + } + + @Test + fun `merge dedupes equivalent endpoint forms by endpoint key`() { + val bare = SavedServerRegistry.fromConnection( + url = "https://my-host.example.com", + name = "Remote", + ) + val explicit = SavedServerRegistry.fromConnection( + url = "https://my-host.example.com:4096", + name = "Updated Remote", + allowInsecure = true, + pinned = true, + ) + + val merged = SavedServerRegistry.merge(listOf(bare, explicit)) + + assertEquals(1, merged.size) + assertEquals("https://my-host.example.com", merged.single().endpoint) + assertEquals("https://my-host.example.com:4096", merged.single().endpointKey) + assertTrue(merged.single().allowInsecure) + assertTrue(merged.single().pinned) + } + + @Test + fun `upsert replaces existing server by stable id`() { + val original = SavedServerRegistry.fromConnection( + url = "http://alpha.example.com", + name = "Alpha", + ) + val beta = SavedServerRegistry.fromConnection( + url = "http://beta.example.com", + name = "Beta", + ) + val edited = original.copy( + displayName = "Alpha edited", + username = "jasmin", + defaultWorkspace = "/work/p4oc", + ) + + val updated = SavedServerRegistry.upsert(listOf(original, beta), edited) + + assertEquals(2, updated.size) + assertEquals("Alpha edited", updated.first { it.id == original.id }.displayName) + assertEquals("jasmin", updated.first { it.id == original.id }.username) + assertEquals("/work/p4oc", updated.first { it.id == original.id }.defaultWorkspace) + assertEquals("Beta", updated.first { it.id == beta.id }.displayName) + } + + @Test + fun `merge surfaces migrated last connection and recent servers without data loss`() { + val lastConnection = SavedServerRegistry.fromConnection( + url = "https://alpha.example.com", + name = "Alpha last", + username = "last-user", + ) + val recentDuplicate = SavedServerRegistry.fromConnection( + url = "https://alpha.example.com:4096", + name = "Alpha recent", + allowInsecure = true, + ) + val recentOnly = SavedServerRegistry.fromConnection( + url = "http://beta.example.com:9999", + name = "Beta recent", + ) + + val migrated = SavedServerRegistry.merge(listOf(lastConnection, recentDuplicate, recentOnly)) + + assertEquals(2, migrated.size) + val alpha = migrated.first { it.endpointKey == "https://alpha.example.com:4096" } + assertEquals("https://alpha.example.com", alpha.endpoint) + assertEquals("last-user", alpha.username) + assertTrue(alpha.allowInsecure) + assertTrue(migrated.any { it.displayName == "Beta recent" }) + } + + @Test + fun `saved server model has no password or api key field`() { + val propertyNames = SavedServer::class.java.declaredFields.map { it.name } + + assertFalse(propertyNames.any { it.contains("password", ignoreCase = true) }) + assertFalse(propertyNames.any { it.contains("apiKey", ignoreCase = true) }) + assertFalse(propertyNames.any { it.contains("token", ignoreCase = true) }) + } +} diff --git a/app/src/test/java/dev/blazelight/p4oc/core/network/ServerConnectionRegistryTest.kt b/app/src/test/java/dev/blazelight/p4oc/core/network/ServerConnectionRegistryTest.kt new file mode 100644 index 00000000..faae8142 --- /dev/null +++ b/app/src/test/java/dev/blazelight/p4oc/core/network/ServerConnectionRegistryTest.kt @@ -0,0 +1,146 @@ +package dev.blazelight.p4oc.core.network + +import dev.blazelight.p4oc.core.datastore.SavedServerRegistry +import dev.blazelight.p4oc.core.datastore.SettingsDataStore +import dev.blazelight.p4oc.domain.server.ServerRef +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ServerConnectionRegistryTest { + + @Test + fun `two saved servers keep independent connection states`() = runTest { + val alpha = SavedServerRegistry.fromConnection("http://alpha.example.com", "Alpha") + val beta = SavedServerRegistry.fromConnection("http://beta.example.com", "Beta") + val alphaManager = successfulManager(alpha) + val betaManager = successfulManager(beta) + val registry = registryFor(this) { config -> + when (config.url) { + alpha.endpoint -> alphaManager + beta.endpoint -> betaManager + else -> error("unexpected config $config") + } + } + + registry.connect(alpha) + registry.connect(beta) + advanceUntilIdle() + + assertEquals(ConnectionState.Connected, registry.connectionState(alpha.toServerRef()).value) + assertEquals(ConnectionState.Connected, registry.connectionState(beta.toServerRef()).value) + coVerify(exactly = 1) { alphaManager.connect(alpha.toServerConfig(), null) } + coVerify(exactly = 1) { betaManager.connect(beta.toServerConfig(), null) } + } + + @Test + fun `one server failure does not overwrite another server state`() = runTest { + val alpha = SavedServerRegistry.fromConnection("http://alpha.example.com", "Alpha") + val beta = SavedServerRegistry.fromConnection("http://beta.example.com", "Beta") + val alphaManager = successfulManager(alpha) + val betaManager = failingManager(beta, "auth failed") + val registry = registryFor(this) { config -> + when (config.url) { + alpha.endpoint -> alphaManager + beta.endpoint -> betaManager + else -> error("unexpected config $config") + } + } + + registry.connect(alpha) + registry.connect(beta) + advanceUntilIdle() + + assertEquals(ConnectionState.Connected, registry.connectionState(alpha.toServerRef()).value) + assertEquals(ConnectionState.Error("auth failed"), registry.connectionState(beta.toServerRef()).value) + } + + @Test + fun `disconnect only clears the targeted server`() = runTest { + val alpha = SavedServerRegistry.fromConnection("http://alpha.example.com", "Alpha") + val beta = SavedServerRegistry.fromConnection("http://beta.example.com", "Beta") + val alphaManager = successfulManager(alpha) + val betaManager = successfulManager(beta) + val registry = registryFor(this) { config -> + when (config.url) { + alpha.endpoint -> alphaManager + beta.endpoint -> betaManager + else -> error("unexpected config $config") + } + } + registry.connect(alpha) + registry.connect(beta) + advanceUntilIdle() + + registry.disconnect(alpha.toServerRef()) + + assertEquals(ConnectionState.Disconnected, registry.connectionState(alpha.toServerRef()).value) + assertEquals(ConnectionState.Connected, registry.connectionState(beta.toServerRef()).value) + coVerify(exactly = 1) { alphaManager.disconnect() } + coVerify(exactly = 0) { betaManager.disconnect() } + } + + @Test + fun `reconnectAll reconnects only saved open-tab servers`() = runTest { + val alpha = SavedServerRegistry.fromConnection("http://alpha.example.com", "Alpha") + val beta = SavedServerRegistry.fromConnection("http://beta.example.com", "Beta") + val missing = ServerRef.fromEndpoint("http://missing.example.com") + val settings = mockk() + coEvery { settings.getSavedServers() } returns listOf(alpha, beta) + coEvery { settings.getSavedServerPassword(alpha) } returns "alpha-pass" + coEvery { settings.getSavedServerPassword(beta) } returns "beta-pass" + val alphaManager = successfulManager(alpha) + val betaManager = successfulManager(beta) + val registry = ServerConnectionRegistry(settings, { config -> + when (config.url) { + alpha.endpoint -> alphaManager + beta.endpoint -> betaManager + else -> error("unexpected config $config") + } + }, this) + + registry.reconnectAll(setOf(alpha.toServerRef(), missing)) + advanceUntilIdle() + + assertEquals(ConnectionState.Connected, registry.connectionState(alpha.toServerRef()).value) + assertEquals(ConnectionState.Disconnected, registry.connectionState(beta.toServerRef()).value) + coVerify(exactly = 1) { alphaManager.connect(alpha.toServerConfig(), "alpha-pass") } + coVerify(exactly = 0) { betaManager.connect(any(), any()) } + } + + private fun registryFor( + scope: CoroutineScope, + factory: (ServerConfig) -> ConnectionManager, + ): ServerConnectionRegistry = ServerConnectionRegistry(mockk(relaxed = true), factory, scope) + + private fun successfulManager(server: dev.blazelight.p4oc.core.datastore.SavedServer): ConnectionManager { + val manager = mockk(relaxed = true) + every { manager.connection } returns MutableStateFlow(null) + every { manager.connectionState } returns MutableStateFlow(ConnectionState.Connected) + coEvery { manager.connect(server.toServerConfig(), any()) } returns Result.success(emptyList()) + return manager + } + + private fun failingManager( + server: dev.blazelight.p4oc.core.datastore.SavedServer, + message: String, + ): ConnectionManager { + val manager = mockk(relaxed = true) + every { manager.connection } returns MutableStateFlow(null) + every { manager.connectionState } returns MutableStateFlow(ConnectionState.Error(message)) + coEvery { manager.connect(server.toServerConfig(), any()) } returns Result.failure(IllegalStateException(message)) + return manager + } +} diff --git a/app/src/test/java/dev/blazelight/p4oc/data/session/SessionRepositoryProviderTest.kt b/app/src/test/java/dev/blazelight/p4oc/data/session/SessionRepositoryProviderTest.kt index 20589515..d71fafaa 100644 --- a/app/src/test/java/dev/blazelight/p4oc/data/session/SessionRepositoryProviderTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/data/session/SessionRepositoryProviderTest.kt @@ -75,6 +75,35 @@ class SessionRepositoryProviderTest { assertNotSame(first.workspaceClient, second.workspaceClient) } + @Test + fun `same directory on different servers gets separate repositories`() { + val provider = provider() + val otherServer = ServerRef.fromEndpointKey("http://other.test:4096") + val otherWorkspace = Workspace(server = otherServer, directory = workspace.directory.orEmpty()) + + val first = provider.acquire(workspace, generation) + val second = provider.acquire(otherWorkspace, generation) + + assertNotSame(first.repository, second.repository) + assertNotSame(first.workspaceClient, second.workspaceClient) + } + + @Test + fun `reconnect generation recreates only affected server workspace owner`() { + val provider = provider() + val otherServer = ServerRef.fromEndpointKey("http://other.test:4096") + val otherWorkspace = Workspace(server = otherServer, directory = workspace.directory.orEmpty()) + val first = provider.acquire(workspace, generation) + val other = provider.acquire(otherWorkspace, generation) + + provider.release(workspace, generation) + val afterReconnect = provider.acquire(workspace, ServerGeneration(2)) + val otherAgain = provider.acquire(otherWorkspace, generation) + + assertNotSame(first.repository, afterReconnect.repository) + assertSame(other.repository, otherAgain.repository) + } + @Test fun `provider routes scoped events to shared repository`() = runTest { val event = sessionCreatedEvent("s1") diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/attention/AttentionBadgeRegistryTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/attention/AttentionBadgeRegistryTest.kt new file mode 100644 index 00000000..37b2a1a6 --- /dev/null +++ b/app/src/test/java/dev/blazelight/p4oc/ui/attention/AttentionBadgeRegistryTest.kt @@ -0,0 +1,41 @@ +package dev.blazelight.p4oc.ui.attention + +import dev.blazelight.p4oc.domain.server.ServerRef +import dev.blazelight.p4oc.domain.server.WorkspaceKey +import org.junit.Assert.assertEquals +import org.junit.Test + +class AttentionBadgeRegistryTest { + @Test + fun `attention state is isolated by server workspace and tab`() { + val alpha = ServerRef.fromEndpointKey("http://alpha.example:4096") + val beta = ServerRef.fromEndpointKey("http://beta.example:4096") + val workspace = WorkspaceKey.Directory("/repo") + val registry = AttentionBadgeRegistry() + + registry.set(AttentionSignal(AttentionKey(alpha, workspace, "tab-a"), AttentionSeverity.Warning, "awaiting input")) + registry.set(AttentionSignal(AttentionKey(beta, workspace, "tab-b"), AttentionSeverity.Error, "auth")) + + val state = registry.state.value + assertEquals(2, state.homeCount) + assertEquals(1, state.forServer(alpha).size) + assertEquals(1, state.forServer(beta).size) + assertEquals(1, state.forWorkspace(alpha, workspace).size) + assertEquals(1, state.forTab("tab-a").size) + } + + @Test + fun `clearing focused tab updates aggregate badge without touching other servers`() { + val alpha = ServerRef.fromEndpointKey("http://alpha.example:4096") + val beta = ServerRef.fromEndpointKey("http://beta.example:4096") + val registry = AttentionBadgeRegistry() + registry.set(AttentionSignal(AttentionKey(alpha, tabId = "tab-a"), AttentionSeverity.Info, "done")) + registry.set(AttentionSignal(AttentionKey(beta, tabId = "tab-b"), AttentionSeverity.Error, "auth")) + + registry.clearTab("tab-a") + + assertEquals(1, registry.state.value.homeCount) + assertEquals(0, registry.state.value.forServer(alpha).size) + assertEquals(1, registry.state.value.forServer(beta).size) + } +} diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/screens/home/HomeSummaryBuilderTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/screens/home/HomeSummaryBuilderTest.kt new file mode 100644 index 00000000..0b1cb915 --- /dev/null +++ b/app/src/test/java/dev/blazelight/p4oc/ui/screens/home/HomeSummaryBuilderTest.kt @@ -0,0 +1,62 @@ +package dev.blazelight.p4oc.ui.screens.home + +import dev.blazelight.p4oc.core.datastore.SavedServerRegistry +import dev.blazelight.p4oc.core.network.ConnectionState +import dev.blazelight.p4oc.domain.server.ServerRef +import dev.blazelight.p4oc.domain.server.WorkspaceKey +import dev.blazelight.p4oc.ui.navigation.Screen +import dev.blazelight.p4oc.ui.tabs.TabInstance +import dev.blazelight.p4oc.ui.tabs.TabState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class HomeSummaryBuilderTest { + @Test + fun `bounded summary uses open tabs without chat histories`() { + val server = SavedServerRegistry.fromConnection("http://alpha.example.com", "Alpha") + val serverRef = ServerRef.fromEndpointKey(server.endpointKey, server.displayName) + val tabs = (1..30).map { index -> + TabInstance( + state = TabState( + id = "tab-$index", + workspaceKey = WorkspaceKey.Directory("/repo-$index"), + serverRef = serverRef, + sessionId = "session-$index", + sessionTitle = "Session $index", + ), + startRoute = Screen.Chat.createRoute("session-$index"), + ) + } + + val summary = HomeSummaryBuilder.build( + savedServers = listOf(server), + connectionStates = mapOf(server.endpointKey to ConnectionState.Connected), + tabs = tabs, + workspaceLimit = 5, + openWorkLimit = 7, + ) + + assertEquals(1, summary.servers.size) + assertEquals(30, summary.servers.single().openTabCount) + assertEquals(7, summary.openWork.size) + assertEquals(5, summary.workspaces.size) + assertEquals("tab-1", summary.openWork.first().tabId) + } + + @Test + fun `offline server summary does not block connected server summary`() { + val connected = SavedServerRegistry.fromConnection("http://alpha.example.com", "Alpha") + val offline = SavedServerRegistry.fromConnection("http://beta.example.com", "Beta") + val summary = HomeSummaryBuilder.build( + savedServers = listOf(connected, offline), + connectionStates = mapOf(connected.endpointKey to ConnectionState.Connected), + tabs = emptyList(), + ) + + assertEquals(2, summary.servers.size) + assertEquals(ConnectionState.Connected, summary.servers.first { it.displayName == "Alpha" }.connectionState) + assertEquals(ConnectionState.Disconnected, summary.servers.first { it.displayName == "Beta" }.connectionState) + assertTrue(summary.partialFailures.isEmpty()) + } +} diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/screens/server/ServerViewModelIssue31Test.kt b/app/src/test/java/dev/blazelight/p4oc/ui/screens/server/ServerViewModelIssue31Test.kt index 8ad176d9..9e08d95c 100644 --- a/app/src/test/java/dev/blazelight/p4oc/ui/screens/server/ServerViewModelIssue31Test.kt +++ b/app/src/test/java/dev/blazelight/p4oc/ui/screens/server/ServerViewModelIssue31Test.kt @@ -1,6 +1,7 @@ package dev.blazelight.p4oc.ui.screens.server import dev.blazelight.p4oc.core.datastore.RecentServer +import dev.blazelight.p4oc.core.datastore.SavedServer import dev.blazelight.p4oc.core.datastore.SettingsDataStore import dev.blazelight.p4oc.core.network.ConnectionManager import dev.blazelight.p4oc.core.network.DiscoveryState @@ -48,8 +49,10 @@ class ServerViewModelIssue31Test { val discoveryManager = mockk() val savedConfig = slot() val recentUrl = slot() + val savedUrl = slot() every { settingsDataStore.recentServers } returns flowOf>(emptyList()) + every { settingsDataStore.savedServers } returns flowOf>(emptyList()) coEvery { settingsDataStore.getLastConnection() } returns null coEvery { settingsDataStore.saveLastConnection(capture(savedConfig), any()) } returns Unit coEvery { @@ -61,6 +64,23 @@ class ServerViewModelIssue31Test { allowInsecure = any(), ) } returns Unit + coEvery { + settingsDataStore.addSavedServer( + url = capture(savedUrl), + name = any(), + username = any(), + password = any(), + allowInsecure = any(), + pinned = any(), + defaultWorkspace = any(), + lastConnectedAt = any(), + ) + } returns SavedServer( + id = "https://my-host.example.com:443", + endpoint = "https://my-host.example.com", + endpointKey = "https://my-host.example.com:443", + displayName = "Remote Server", + ) coEvery { connectionManager.connect(any(), any()) } returns Result.success(emptyList()) every { discoveryManager.discoveredServers } returns MutableStateFlow(emptyList()) every { discoveryManager.discoveryState } returns MutableStateFlow(DiscoveryState.IDLE) @@ -87,7 +107,20 @@ class ServerViewModelIssue31Test { allowInsecure = any(), ) } + coVerify { + settingsDataStore.addSavedServer( + url = any(), + name = any(), + username = any(), + password = any(), + allowInsecure = any(), + pinned = any(), + defaultWorkspace = any(), + lastConnectedAt = any(), + ) + } assertEquals("https://my-host.example.com", savedConfig.captured.url) assertEquals("https://my-host.example.com", recentUrl.captured) + assertEquals("https://my-host.example.com", savedUrl.captured) } } diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/tabs/StartWorkContextTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/tabs/StartWorkContextTest.kt new file mode 100644 index 00000000..768550ea --- /dev/null +++ b/app/src/test/java/dev/blazelight/p4oc/ui/tabs/StartWorkContextTest.kt @@ -0,0 +1,58 @@ +package dev.blazelight.p4oc.ui.tabs + +import dev.blazelight.p4oc.domain.server.ServerRef +import dev.blazelight.p4oc.domain.server.WorkspaceKey +import dev.blazelight.p4oc.ui.navigation.Screen +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class StartWorkContextTest { + private val server = ServerRef.fromEndpointKey("http://alpha.example:4096") + private val workspace = WorkspaceKey.Directory("/repo") + + @Test + fun `Home defaults to target picker with no implicit server or workspace`() { + val context = startWorkContextFor(TabInstance.home()) + + assertEquals(StartWorkSource.HomeTopLevel, context.source) + assertNull(context.defaultServer) + assertNull(context.defaultWorkspace) + assertEquals(StartWorkAction.ChooseAnotherTarget, context.defaultAction) + assertFalse(context.hasExplicitTarget) + } + + @Test + fun `chat tab defaults to the tab server and workspace`() { + val tab = TabInstance( + state = TabState(workspaceKey = workspace, serverRef = server, sessionId = "s1"), + startRoute = Screen.Chat.createRoute("s1"), + ) + + val context = startWorkContextFor(tab) + + assertEquals(StartWorkSource.ChatTab, context.source) + assertEquals(server, context.defaultServer) + assertEquals(workspace, context.defaultWorkspace) + assertTrue(context.hasExplicitTarget) + } + + @Test + fun `files and terminal tabs default to their tab target`() { + val files = startWorkContextFor( + TabInstance(TabState(workspaceKey = workspace, serverRef = server), Screen.Files.route), + ) + val terminal = startWorkContextFor( + TabInstance(TabState(workspaceKey = workspace, serverRef = server), Screen.Terminal.createRoute("pty-1")), + ) + + assertEquals(StartWorkSource.FilesTab, files.source) + assertEquals(server, files.defaultServer) + assertEquals(workspace, files.defaultWorkspace) + assertEquals(StartWorkSource.TerminalTab, terminal.source) + assertEquals(server, terminal.defaultServer) + assertEquals(workspace, terminal.defaultWorkspace) + } +} diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/tabs/TabManagerPersistenceTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/tabs/TabManagerPersistenceTest.kt index bfdaad19..25e69bf3 100644 --- a/app/src/test/java/dev/blazelight/p4oc/ui/tabs/TabManagerPersistenceTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/ui/tabs/TabManagerPersistenceTest.kt @@ -20,6 +20,7 @@ class TabManagerPersistenceTest { val tab = manager.createTab( startRoute = Screen.Sessions.route, workspaceKey = WorkspaceKey.Global, + serverRef = server, focus = true, ) manager.updateTabWorkspace(tab.id, WorkspaceKey.Directory("/repo/a")) @@ -33,6 +34,7 @@ class TabManagerPersistenceTest { assertEquals("s1", saved.tabs.single().sessionId) assertEquals(PersistedWorkspaceKey.Type.DIRECTORY, saved.tabs.single().workspaceKey?.type) assertEquals("/repo/a", saved.tabs.single().workspaceKey?.value) + assertEquals(server.endpointKey, saved.tabs.single().serverEndpointKey) } @Test @@ -56,9 +58,11 @@ class TabManagerPersistenceTest { assertTrue(result is RestoreResult.Restored) assertEquals("tab-1", manager.activeTabId.value) - assertEquals("session with space", manager.tabs.value.single().sessionId) - assertEquals("/repo/a b", manager.tabs.value.single().workspaceDirectory) - assertEquals("chat/session%20with%20space", manager.tabs.value.single().startRoute) + val workTab = manager.tabs.value.single { !it.isPinnedHome } + assertEquals("session with space", workTab.sessionId) + assertEquals("/repo/a b", workTab.workspaceDirectory) + assertEquals(server.endpointKey, workTab.serverEndpointKey) + assertEquals("chat/session%20with%20space", workTab.startRoute) } @Test @@ -79,7 +83,8 @@ class TabManagerPersistenceTest { val result = manager.restoreState(state, server) assertTrue(result is RestoreResult.Empty) - assertFalse(manager.hasTabs()) + assertEquals(listOf(TabInstance.HOME_TAB_ID), manager.tabs.value.map { it.id }) + assertEquals(TabInstance.HOME_TAB_ID, manager.activeTabId.value) } @Test @@ -111,25 +116,35 @@ class TabManagerPersistenceTest { assertTrue(result is RestoreResult.Restored) assertEquals(2, (result as RestoreResult.Restored).count) - assertEquals(listOf("directory-tab", "global-tab"), manager.tabs.value.map { it.id }) + val workTabs = manager.tabs.value.filterNot { it.isPinnedHome } + assertEquals(listOf("directory-tab", "global-tab"), workTabs.map { it.id }) assertEquals("directory-tab", manager.activeTabId.value) - assertEquals("/repo/valid", manager.tabs.value[0].workspaceDirectory) - assertEquals(WorkspaceKey.Global, manager.tabs.value[1].workspaceKey) + assertEquals("/repo/valid", workTabs[0].workspaceDirectory) + assertEquals(WorkspaceKey.Global, workTabs[1].workspaceKey) } @Test - fun `restoreState rejects mismatched active server without tabs`() { + fun `restoreState reports missing server without restoring wrong server`() { val manager = TabManager() + val oldServer = ServerRef.fromEndpointKey("http://old.example:4096") val state = PersistedTabState( - serverEndpointKey = "http://old.example", + serverEndpointKey = oldServer.endpointKey, activeTabId = "tab-1", - tabs = listOf(PersistedTab(id = "tab-1", startRoute = Screen.Sessions.route)), + tabs = listOf( + PersistedTab( + id = "tab-1", + startRoute = Screen.Sessions.route, + workspaceKey = PersistedWorkspaceKey(PersistedWorkspaceKey.Type.GLOBAL), + serverEndpointKey = oldServer.endpointKey, + ), + ), ) - val result = manager.restoreState(state, server) + val result = manager.restoreState(state, mapOf(server.endpointKey to server)) - assertTrue(result is RestoreResult.ServerMismatch) - assertFalse(manager.hasTabs()) + assertTrue(result is RestoreResult.MissingServer) + assertEquals(oldServer.endpointKey, (result as RestoreResult.MissingServer).endpointKey) + assertEquals(listOf(TabInstance.HOME_TAB_ID), manager.tabs.value.map { it.id }) } @Test @@ -148,12 +163,202 @@ class TabManagerPersistenceTest { assertFalse(manager.hasTabs()) } + @Test + fun `restoreState restores mixed-server tabs when all servers are available`() { + val manager = TabManager() + val beta = ServerRef.fromEndpointKey("http://beta.example:4096") + val state = PersistedTabState( + serverEndpointKey = server.endpointKey, + activeTabId = "beta-files", + tabs = listOf( + PersistedTab( + id = "alpha-chat", + startRoute = Screen.Sessions.route, + sessionId = "s-alpha", + workspaceKey = PersistedWorkspaceKey(PersistedWorkspaceKey.Type.DIRECTORY, "/alpha"), + serverEndpointKey = server.endpointKey, + ), + PersistedTab( + id = "beta-files", + startRoute = Screen.Files.route, + workspaceKey = PersistedWorkspaceKey(PersistedWorkspaceKey.Type.DIRECTORY, "/beta"), + serverEndpointKey = beta.endpointKey, + ), + ), + ) + + val result = manager.restoreState( + state, + mapOf(server.endpointKey to server, beta.endpointKey to beta), + ) + + assertTrue(result is RestoreResult.Restored) + assertEquals("beta-files", manager.activeTabId.value) + val workTabs = manager.tabs.value.filterNot { it.isPinnedHome } + assertEquals(listOf(server.endpointKey, beta.endpointKey), workTabs.map { it.serverEndpointKey }) + assertEquals(listOf("/alpha", "/beta"), workTabs.map { it.workspaceDirectory }) + } + + @Test + fun `restoreState restores available tabs and reports unavailable mixed server`() { + val manager = TabManager() + val missing = ServerRef.fromEndpointKey("http://missing.example:4096") + val state = PersistedTabState( + serverEndpointKey = server.endpointKey, + activeTabId = "missing-files", + tabs = listOf( + PersistedTab( + id = "alpha-chat", + startRoute = Screen.Sessions.route, + sessionId = "s-alpha", + workspaceKey = PersistedWorkspaceKey(PersistedWorkspaceKey.Type.DIRECTORY, "/alpha"), + serverEndpointKey = server.endpointKey, + ), + PersistedTab( + id = "missing-files", + startRoute = Screen.Files.route, + workspaceKey = PersistedWorkspaceKey(PersistedWorkspaceKey.Type.DIRECTORY, "/missing"), + serverEndpointKey = missing.endpointKey, + ), + ), + ) + + val result = manager.restoreState(state, mapOf(server.endpointKey to server)) + + assertTrue(result is RestoreResult.MissingServer) + assertEquals(missing.endpointKey, (result as RestoreResult.MissingServer).endpointKey) + assertEquals(1, result.restoredCount) + assertEquals(listOf(TabInstance.HOME_TAB_ID, "alpha-chat"), manager.tabs.value.map { it.id }) + assertEquals("alpha-chat", manager.activeTabId.value) + + } + @Test + fun `pinned Home is leftmost non-closeable and not duplicated`() { + val manager = TabManager() + + manager.ensureHomeTab(focus = true) + manager.ensureHomeTab(focus = false) + val work = manager.createTab( + startRoute = Screen.Files.route, + workspaceKey = WorkspaceKey.Global, + serverRef = server, + focus = true, + ) + + assertEquals(listOf(TabInstance.HOME_TAB_ID, work.id), manager.tabs.value.map { it.id }) + manager.closeTab(TabInstance.HOME_TAB_ID) + assertEquals(listOf(TabInstance.HOME_TAB_ID, work.id), manager.tabs.value.map { it.id }) + } + + @Test + fun `saveState does not persist pinned Home as normal tab`() { + val manager = TabManager() + manager.ensureHomeTab(focus = true) + manager.createTab( + startRoute = Screen.Files.route, + workspaceKey = WorkspaceKey.Global, + serverRef = server, + focus = true, + ) + + val saved = manager.saveState(server)!! + + assertEquals(1, saved.tabs.size) + assertEquals(Screen.Files.route, saved.tabs.single().startRoute) + } + + @Test + fun `app restart restores Home plus Alpha chat Beta files and Local terminal safely`() { + val alpha = ServerRef.fromEndpointKey("http://alpha.example:4096") + val beta = ServerRef.fromEndpointKey("http://beta.example:4096") + val local = ServerRef.fromEndpointKey("http://localhost:4096") + val beforeRestart = TabManager() + beforeRestart.ensureHomeTab(focus = false) + val alphaChat = beforeRestart.createTab( + startRoute = Screen.Chat.createRoute("alpha-session"), + workspaceKey = WorkspaceKey.Directory("/alpha"), + serverRef = alpha, + focus = true, + ) + beforeRestart.updateTabSession(alphaChat.id, "alpha-session", "Alpha chat") + beforeRestart.createTab( + startRoute = Screen.Files.route, + workspaceKey = WorkspaceKey.Directory("/beta"), + serverRef = beta, + focus = true, + ) + beforeRestart.createTab( + startRoute = Screen.Terminal.createRoute("pty-local"), + workspaceKey = WorkspaceKey.Directory("/local"), + serverRef = local, + focus = true, + ) + val persisted = beforeRestart.saveState(alpha)!! + + val afterRestart = TabManager() + val result = afterRestart.restoreState( + persisted, + mapOf(alpha.endpointKey to alpha, beta.endpointKey to beta, local.endpointKey to local), + ) + + assertTrue(result is RestoreResult.Restored) + assertEquals(TabInstance.HOME_TAB_ID, afterRestart.tabs.value.first().id) + val restoredWorkTabs = afterRestart.tabs.value.filterNot { it.isPinnedHome } + assertEquals(listOf(alpha.endpointKey, beta.endpointKey, local.endpointKey), restoredWorkTabs.map { it.serverEndpointKey }) + assertEquals(listOf("/alpha", "/beta", "/local"), restoredWorkTabs.map { it.workspaceDirectory }) + assertEquals("chat/alpha-session", restoredWorkTabs[0].startRoute) + assertEquals(Screen.Files.route, restoredWorkTabs[1].startRoute) + assertEquals(Screen.Sessions.route, restoredWorkTabs[2].startRoute) + } + + @Test + fun `focusOrCreateFilesTab focuses existing files tab for server workspace`() { + val manager = TabManager() + val workspace = WorkspaceKey.Directory("/repo") + val first = manager.focusOrCreateFilesTab(server, workspace) + val second = manager.focusOrCreateFilesTab(server, workspace) + + assertEquals(first.id, second.id) + assertEquals(first.id, manager.activeTabId.value) + assertEquals(1, manager.tabs.value.count { it.startRoute == Screen.Files.route && !it.isPinnedHome }) + } + + @Test + fun `files focus helper separates identical workspaces on different servers`() { + val manager = TabManager() + val workspace = WorkspaceKey.Directory("/repo") + val otherServer = ServerRef.fromEndpointKey("http://other.example:4096") + val first = manager.focusOrCreateFilesTab(server, workspace) + val second = manager.focusOrCreateFilesTab(otherServer, workspace) + + assertEquals(2, manager.tabs.value.count { it.startRoute == Screen.Files.route && !it.isPinnedHome }) + assertEquals(listOf(server.endpointKey, otherServer.endpointKey), listOf(first.serverEndpointKey, second.serverEndpointKey)) + } + + @Test + fun `terminal tabs are found by explicit server workspace`() { + val manager = TabManager() + val workspace = WorkspaceKey.Directory("/repo") + val otherWorkspace = WorkspaceKey.Directory("/other") + manager.createTab(Screen.Terminal.createRoute("pty-1"), workspace, server, focus = true) + manager.createTab(Screen.Terminal.createRoute("pty-2"), otherWorkspace, server, focus = true) + + assertEquals(listOf("terminal/pty-1"), manager.findTerminalTabs(server, workspace).map { it.startRoute }) + } + + @Test + fun `createPtyRequestForWorkspace uses target workspace cwd`() { + assertEquals("/repo", createPtyRequestForWorkspace(WorkspaceKey.Directory("/repo")).cwd) + assertEquals(null, createPtyRequestForWorkspace(WorkspaceKey.Global).cwd) + } + @Test fun `terminal routes are not persisted as resurrectable tabs`() { val manager = TabManager() manager.createTab( startRoute = Screen.Terminal.createRoute("pty-1"), workspaceKey = WorkspaceKey.Global, + serverRef = server, focus = true, ) @@ -162,3 +367,6 @@ class TabManagerPersistenceTest { assertEquals(Screen.Sessions.route, saved.tabs.single().startRoute) } } + +@Suppress("unused") +private fun mixedServerPersistenceCompileAnchor() {} From 0e434ff024bd5cf89c75291df32a6e004ad1b7a5 Mon Sep 17 00:00:00 2001 From: Jasmin Le Roux Date: Wed, 8 Jul 2026 17:45:01 +0200 Subject: [PATCH 13/22] Clean up detekt baseline nits --- app/detekt-baseline.xml | 21 ------------ .../dev/blazelight/p4oc/ui/tabs/TabState.kt | 3 +- .../network/ServerConnectionRegistryTest.kt | 7 ++-- .../attention/AttentionBadgeRegistryTest.kt | 32 ++++++++++++++++--- .../p4oc/ui/tabs/TabManagerPersistenceTest.kt | 18 ++++++++--- 5 files changed, 46 insertions(+), 35 deletions(-) diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml index 488bf615..6be02640 100644 --- a/app/detekt-baseline.xml +++ b/app/detekt-baseline.xml @@ -2,16 +2,8 @@ - ArgumentListWrapping:AttentionBadgeRegistryTest.kt$AttentionBadgeRegistryTest$(AttentionKey(alpha, workspace, "tab-a"), AttentionSeverity.Warning, "awaiting input") - ArgumentListWrapping:AttentionBadgeRegistryTest.kt$AttentionBadgeRegistryTest$(AttentionSignal(AttentionKey(alpha, workspace, "tab-a"), AttentionSeverity.Warning, "awaiting input")) - ArgumentListWrapping:AttentionBadgeRegistryTest.kt$AttentionBadgeRegistryTest$(alpha, workspace, "tab-a") ArgumentListWrapping:FileExplorerScreen.kt$(Icons.AutoMirrored.Filled.NoteAdd, contentDescription = null, tint = theme.textMuted) ArgumentListWrapping:ServerConnectionRegistry.kt$ServerConnectionRegistry$(serverRef.endpointKey) - ArgumentListWrapping:TabManagerPersistenceTest.kt$TabManagerPersistenceTest$(alpha.endpointKey, beta.endpointKey, local.endpointKey) - ArgumentListWrapping:TabManagerPersistenceTest.kt$TabManagerPersistenceTest$(first.serverEndpointKey, second.serverEndpointKey) - ArgumentListWrapping:TabManagerPersistenceTest.kt$TabManagerPersistenceTest$(listOf(alpha.endpointKey, beta.endpointKey, local.endpointKey), restoredWorkTabs.map { it.serverEndpointKey }) - ArgumentListWrapping:TabManagerPersistenceTest.kt$TabManagerPersistenceTest$(listOf(server.endpointKey, otherServer.endpointKey), listOf(first.serverEndpointKey, second.serverEndpointKey)) - ArgumentListWrapping:TabManagerPersistenceTest.kt$TabManagerPersistenceTest$(server.endpointKey, otherServer.endpointKey) ArgumentListWrapping:TabNavHost.kt$(backStackEntry, navController, workspaceRoute, workspaceOwner, backStackEntry.destination.route) ArgumentListWrapping:ToolGroupWidget.kt$(pendingPermissionIdsByCallId[tool.callID] ?: tool.callID) CommentWrapping:SoraCodeEditorView.kt$/* autoComplete = */ @@ -283,7 +275,6 @@ ImportOrdering:HomeScreen.kt$import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Terminal import androidx.compose.material.icons.filled.ViewList import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.platform.testTag import dev.blazelight.p4oc.ui.theme.LocalOpenCodeTheme import dev.blazelight.p4oc.domain.server.WorkspaceKey import dev.blazelight.p4oc.ui.theme.Spacing import dev.blazelight.p4oc.ui.theme.TuiShapes ImportOrdering:SettingsDataStore.kt$import android.content.Context import androidx.datastore.core.DataMigration import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.* import androidx.datastore.preferences.preferencesDataStore import dev.blazelight.p4oc.core.log.AppLog import dev.blazelight.p4oc.core.security.CredentialStore import dev.blazelight.p4oc.core.network.ServerUrl import dev.blazelight.p4oc.data.remote.dto.ModelInput import dev.blazelight.p4oc.domain.server.WorkspaceKey import dev.blazelight.p4oc.domain.session.SessionId import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json ImportOrdering:TabNavHost.kt$import android.net.Uri import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.compose.animation.* import androidx.compose.animation.core.tween import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import androidx.navigation.NavBackStackEntry import androidx.navigation.NavHostController import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.navigation import androidx.navigation.navArgument import dev.blazelight.p4oc.core.datastore.SettingsDataStore import dev.blazelight.p4oc.core.datastore.VisualSettings import dev.blazelight.p4oc.domain.model.SessionConnectionState import dev.blazelight.p4oc.domain.server.WorkspaceKey import dev.blazelight.p4oc.domain.server.ServerRef import dev.blazelight.p4oc.ui.navigation.Screen import dev.blazelight.p4oc.ui.screens.chat.ChatScreen import dev.blazelight.p4oc.ui.screens.diff.DiffViewerScreen import dev.blazelight.p4oc.ui.screens.diff.SessionDiffScreen import dev.blazelight.p4oc.ui.screens.files.FileExplorerScreen import dev.blazelight.p4oc.ui.screens.files.FileViewerScreen import dev.blazelight.p4oc.ui.screens.files.FilesViewModel import dev.blazelight.p4oc.ui.screens.home.HomeScreen import dev.blazelight.p4oc.ui.screens.home.HomeSummaryBuilder import dev.blazelight.p4oc.ui.screens.projects.ProjectsScreen import dev.blazelight.p4oc.ui.screens.sessions.SessionListScreen import dev.blazelight.p4oc.ui.screens.sessions.SessionListViewModel import dev.blazelight.p4oc.ui.screens.settings.* import dev.blazelight.p4oc.ui.screens.terminal.TerminalScreen import dev.blazelight.p4oc.ui.workspace.WorkspaceRepositoryOwner import dev.blazelight.p4oc.ui.workspace.WorkspaceViewModel import org.koin.androidx.compose.koinViewModel import org.koin.compose.koinInject import org.koin.core.parameter.parametersOf - ImportOrdering:TabState.kt$import dev.blazelight.p4oc.domain.model.SessionConnectionState import dev.blazelight.p4oc.domain.server.ServerRef import dev.blazelight.p4oc.ui.navigation.Screen import dev.blazelight.p4oc.domain.server.WorkspaceKey import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import java.util.UUID Indentation:ChatScreen.kt$ InstanceOfCheckForException:DialogQueueManager.kt$DialogQueueManager$e is CancellationException LargeClass:SessionRepositoryImpl.kt$SessionRepositoryImpl : SessionRepository @@ -616,7 +607,6 @@ MatchingDeclarationName:NotificationSettingsScreen.kt$NotificationSettingsViewModel : ViewModel MatchingDeclarationName:TodoDtos.kt$TodoDto MatchingDeclarationName:VisualSettingsScreen.kt$VisualSettingsViewModel : ViewModel - MaxLineLength:AttentionBadgeRegistryTest.kt$AttentionBadgeRegistryTest$registry.set(AttentionSignal(AttentionKey(alpha, workspace, "tab-a"), AttentionSeverity.Warning, "awaiting input")) MaxLineLength:ChatViewModelTest.kt$ChatViewModelTest$"file:/test/src/My%20File%20%25/%C3%BCmlaut/%E3%81%93%E3%82%93%E3%81%AB%E3%81%A1%E3%81%AF/hash%23query%3F.kt" MaxLineLength:ConnectionManager.kt$ConnectionManager$AppLog.w(TAG, "SSE remained in Error after ${settings.reconnectTimeoutSeconds}s; escalating to Disconnected: ${state.message}") MaxLineLength:ConnectionManager.kt$ConnectionManager$level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.NONE @@ -639,7 +629,6 @@ MaxLineLength:ParsedDiff.kt$ParsedDiffParser$if MaxLineLength:PartDtos.kt$PartDto$val type: String MaxLineLength:ServerConnectionRegistry.kt$ServerConnectionRegistry$fun connectionState(serverRef: ServerRef): StateFlow<ConnectionState> - MaxLineLength:ServerConnectionRegistryTest.kt$ServerConnectionRegistryTest$coEvery { manager.connect(server.toServerConfig(), any()) } returns Result.failure(IllegalStateException(message)) MaxLineLength:ServerScreen.kt$text = "${server.endpoint} · ${if (server.allowInsecure) "TLS checks off" else "TLS checks on"}" MaxLineLength:SessionListScreen.kt$text MaxLineLength:SessionListViewModelTest.kt$SessionListViewModelTest$Pair("/project", "apple") to listOf(FakeWorkspaceClient.sessionDto("project", title = "apple project", directory = "/project")) @@ -661,14 +650,11 @@ MaxLineLength:SettingsDataStore.kt$SettingsDataStore$?: MaxLineLength:TabManager.kt$TabManager$return missingServerEndpointKeys.firstOrNull()?.let { RestoreResult.MissingServer(it) } ?: RestoreResult.Empty MaxLineLength:TabManager.kt$TabManager$val serverEndpointKey = persisted.resolvedServerEndpointKey(state.serverEndpointKey) ?: return@mapNotNull null - MaxLineLength:TabManagerPersistenceTest.kt$TabManagerPersistenceTest$assertEquals(listOf(alpha.endpointKey, beta.endpointKey, local.endpointKey), restoredWorkTabs.map { it.serverEndpointKey }) - MaxLineLength:TabManagerPersistenceTest.kt$TabManagerPersistenceTest$assertEquals(listOf(server.endpointKey, otherServer.endpointKey), listOf(first.serverEndpointKey, second.serverEndpointKey)) MaxLineLength:TabNavHost.kt$TouchWorkspaceViewModel(backStackEntry, navController, workspaceRoute, workspaceOwner, backStackEntry.destination.route) MaxLineLength:ToolGroupWidget.kt$onApprove = { onToolApprove(pendingPermissionIdsByCallId[tool.callID] ?: tool.callID) } MaxLineLength:ToolGroupWidget.kt$toolList.any { it.state is ToolState.Pending || it.callID in pendingPermissionIdsByCallId.keys } -> AggregateToolState.PENDING MaxLineLength:WorkspaceFileRepositoryTest.kt$WorkspaceFileRepositoryTest$uri = "file:///src/My%20File%20%25/%C3%BCmlaut/%E3%81%93%E3%82%93%E3%81%AB%E3%81%A1%E3%81%AF/hash%23query%3F.kt" MaxLineLength:WorkspaceRepositoryOwner.kt$WorkspaceRepositoryOwner$"WorkspaceRepositoryOwner.$event tabId=$tabId workspaceKey=${workspace.key} server=${workspace.server.endpointKey} generation=${generation.value} identity=$identityHash" - MaximumLineLength:AttentionBadgeRegistryTest.kt$AttentionBadgeRegistryTest$ MaximumLineLength:ChatViewModelTest.kt$ChatViewModelTest$ MaximumLineLength:ConnectionManager.kt$ConnectionManager$ MaximumLineLength:FileExplorerScreen.kt$ @@ -682,7 +668,6 @@ MaximumLineLength:ParsedDiff.kt$ParsedDiffParser$ MaximumLineLength:PartDtos.kt$PartDto$ MaximumLineLength:ServerConnectionRegistry.kt$ServerConnectionRegistry$ - MaximumLineLength:ServerConnectionRegistryTest.kt$ServerConnectionRegistryTest$ MaximumLineLength:ServerScreen.kt$ MaximumLineLength:SessionListScreen.kt$ MaximumLineLength:SessionListViewModelTest.kt$SessionListViewModelTest$ @@ -690,7 +675,6 @@ MaximumLineLength:SessionRepositoryImplTest.kt$SessionRepositoryImplTest$ MaximumLineLength:SettingsDataStore.kt$SettingsDataStore$ MaximumLineLength:TabManager.kt$TabManager$ - MaximumLineLength:TabManagerPersistenceTest.kt$TabManagerPersistenceTest$ MaximumLineLength:TabNavHost.kt$ MaximumLineLength:ToolGroupWidget.kt$ MaximumLineLength:WorkspaceFileRepositoryTest.kt$WorkspaceFileRepositoryTest$ @@ -699,15 +683,12 @@ MultiLineIfElse:FilePickerDialog.kt$stringResource(R.string.empty_folder) NestedBlockDepth:OfishMutationClient.kt$OfishMutationClient$private suspend fun uploadInSession( sessionId: String, path: String, request: FileUploadRequest, capabilities: OfishCapabilities, ): FileOperationResult<FileUploadResult> NestedBlockDepth:StreamingMarkdown.kt$internal fun parseMarkdownBlocks(text: String): List<MarkdownBlock> - NoBlankLineBeforeRbrace:TabManagerPersistenceTest.kt$TabManagerPersistenceTest$ NoBlankLineBeforeRbrace:VisualSettingsScreen.kt$ NoConsecutiveBlankLines:ChatViewModelTest.kt$ChatViewModelTest$ NoConsecutiveBlankLines:ComponentPreviews.kt$ - NoConsecutiveBlankLines:TabState.kt$TabState$ NoSemicolons:CommandPalette.kt$; NoSemicolons:TextMateAnnotatedStringTest.kt$TextMateAnnotatedStringTest.Companion$; NoTrailingSpaces:ToolGroupWidget.kt$ - NoUnusedImports:ServerConnectionRegistryTest.kt$dev.blazelight.p4oc.core.network.ServerConnectionRegistryTest.kt NoWildcardImports:AgentsConfigScreen.kt$import androidx.compose.foundation.layout.* NoWildcardImports:AgentsConfigScreen.kt$import androidx.compose.material.icons.filled.* NoWildcardImports:AgentsConfigScreen.kt$import androidx.compose.material3.* @@ -908,7 +889,6 @@ ReturnCount:UploadOrchestrator.kt$UploadOrchestrator$private suspend fun uploadOne(index: Int) ReturnCount:UploadVisuals.kt$fun getMimeTypeLabel(mimeType: String?): String SpacingBetweenDeclarationsWithAnnotations:SettingsDataStore.kt$PersistedWorkspaceKey - SpacingBetweenDeclarationsWithAnnotations:TabManagerPersistenceTest.kt$TabManagerPersistenceTest$@Test fun `pinned Home is leftmost non-closeable and not duplicated`() SwallowedException:ChatInputBar.kt$e: Exception SwallowedException:Mappers.kt$MessageMapper$e: Exception SwallowedException:SettingsDataStore.kt$SettingsDataStore$e: Exception @@ -1104,7 +1084,6 @@ Wrapping:ServerScreen.kt$Text( stringResource(R.string.field_server_url_placeholder), fontFamily = FontFamily.Monospace ) Wrapping:SessionListViewModel.kt$SessionListViewModel$-> Wrapping:SessionListViewModel.kt$SessionListViewModel$it.copy( isLoading = false, loadingText = null, loadingProgress = null, loadingCounts = null, error = "Switch to $directory before creating a session" ) - Wrapping:TabManagerPersistenceTest.kt$TabManagerPersistenceTest$it.serverEndpointKey Wrapping:ToolGroupWidget.kt$it.state is ToolState.Pending || it.callID in pendingPermissionIdsByCallId.keys diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabState.kt b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabState.kt index c71bfd73..04e31880 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabState.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabState.kt @@ -2,8 +2,8 @@ package dev.blazelight.p4oc.ui.tabs import dev.blazelight.p4oc.domain.model.SessionConnectionState import dev.blazelight.p4oc.domain.server.ServerRef -import dev.blazelight.p4oc.ui.navigation.Screen import dev.blazelight.p4oc.domain.server.WorkspaceKey +import dev.blazelight.p4oc.ui.navigation.Screen import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -39,7 +39,6 @@ data class TabState( /** Server endpoint owned by this tab. Null is allowed only for explicitly global surfaces. */ val serverRef: ServerRef? = null, - /** Pinned Home is global, leftmost, non-closeable, and not a work tab. */ val pinnedHome: Boolean = false, /** Incremented when workspace changes so navigation graph scoped ViewModels are recreated. */ diff --git a/app/src/test/java/dev/blazelight/p4oc/core/network/ServerConnectionRegistryTest.kt b/app/src/test/java/dev/blazelight/p4oc/core/network/ServerConnectionRegistryTest.kt index faae8142..78e0d637 100644 --- a/app/src/test/java/dev/blazelight/p4oc/core/network/ServerConnectionRegistryTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/core/network/ServerConnectionRegistryTest.kt @@ -10,12 +10,9 @@ import io.mockk.mockk import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) @@ -140,7 +137,9 @@ class ServerConnectionRegistryTest { val manager = mockk(relaxed = true) every { manager.connection } returns MutableStateFlow(null) every { manager.connectionState } returns MutableStateFlow(ConnectionState.Error(message)) - coEvery { manager.connect(server.toServerConfig(), any()) } returns Result.failure(IllegalStateException(message)) + coEvery { manager.connect(server.toServerConfig(), any()) } returns Result.failure( + IllegalStateException(message), + ) return manager } } diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/attention/AttentionBadgeRegistryTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/attention/AttentionBadgeRegistryTest.kt index 37b2a1a6..291b9b17 100644 --- a/app/src/test/java/dev/blazelight/p4oc/ui/attention/AttentionBadgeRegistryTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/ui/attention/AttentionBadgeRegistryTest.kt @@ -13,8 +13,20 @@ class AttentionBadgeRegistryTest { val workspace = WorkspaceKey.Directory("/repo") val registry = AttentionBadgeRegistry() - registry.set(AttentionSignal(AttentionKey(alpha, workspace, "tab-a"), AttentionSeverity.Warning, "awaiting input")) - registry.set(AttentionSignal(AttentionKey(beta, workspace, "tab-b"), AttentionSeverity.Error, "auth")) + registry.set( + AttentionSignal( + AttentionKey(alpha, workspace, "tab-a"), + AttentionSeverity.Warning, + "awaiting input", + ), + ) + registry.set( + AttentionSignal( + AttentionKey(beta, workspace, "tab-b"), + AttentionSeverity.Error, + "auth", + ), + ) val state = registry.state.value assertEquals(2, state.homeCount) @@ -29,8 +41,20 @@ class AttentionBadgeRegistryTest { val alpha = ServerRef.fromEndpointKey("http://alpha.example:4096") val beta = ServerRef.fromEndpointKey("http://beta.example:4096") val registry = AttentionBadgeRegistry() - registry.set(AttentionSignal(AttentionKey(alpha, tabId = "tab-a"), AttentionSeverity.Info, "done")) - registry.set(AttentionSignal(AttentionKey(beta, tabId = "tab-b"), AttentionSeverity.Error, "auth")) + registry.set( + AttentionSignal( + AttentionKey(alpha, tabId = "tab-a"), + AttentionSeverity.Info, + "done", + ), + ) + registry.set( + AttentionSignal( + AttentionKey(beta, tabId = "tab-b"), + AttentionSeverity.Error, + "auth", + ), + ) registry.clearTab("tab-a") diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/tabs/TabManagerPersistenceTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/tabs/TabManagerPersistenceTest.kt index 25e69bf3..ad3ed36d 100644 --- a/app/src/test/java/dev/blazelight/p4oc/ui/tabs/TabManagerPersistenceTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/ui/tabs/TabManagerPersistenceTest.kt @@ -230,8 +230,8 @@ class TabManagerPersistenceTest { assertEquals(1, result.restoredCount) assertEquals(listOf(TabInstance.HOME_TAB_ID, "alpha-chat"), manager.tabs.value.map { it.id }) assertEquals("alpha-chat", manager.activeTabId.value) - } + @Test fun `pinned Home is leftmost non-closeable and not duplicated`() { val manager = TabManager() @@ -298,13 +298,20 @@ class TabManagerPersistenceTest { val afterRestart = TabManager() val result = afterRestart.restoreState( persisted, - mapOf(alpha.endpointKey to alpha, beta.endpointKey to beta, local.endpointKey to local), + mapOf( + alpha.endpointKey to alpha, + beta.endpointKey to beta, + local.endpointKey to local, + ), ) assertTrue(result is RestoreResult.Restored) assertEquals(TabInstance.HOME_TAB_ID, afterRestart.tabs.value.first().id) val restoredWorkTabs = afterRestart.tabs.value.filterNot { it.isPinnedHome } - assertEquals(listOf(alpha.endpointKey, beta.endpointKey, local.endpointKey), restoredWorkTabs.map { it.serverEndpointKey }) + assertEquals( + listOf(alpha.endpointKey, beta.endpointKey, local.endpointKey), + restoredWorkTabs.map { it.serverEndpointKey }, + ) assertEquals(listOf("/alpha", "/beta", "/local"), restoredWorkTabs.map { it.workspaceDirectory }) assertEquals("chat/alpha-session", restoredWorkTabs[0].startRoute) assertEquals(Screen.Files.route, restoredWorkTabs[1].startRoute) @@ -332,7 +339,10 @@ class TabManagerPersistenceTest { val second = manager.focusOrCreateFilesTab(otherServer, workspace) assertEquals(2, manager.tabs.value.count { it.startRoute == Screen.Files.route && !it.isPinnedHome }) - assertEquals(listOf(server.endpointKey, otherServer.endpointKey), listOf(first.serverEndpointKey, second.serverEndpointKey)) + assertEquals( + listOf(server.endpointKey, otherServer.endpointKey), + listOf(first.serverEndpointKey, second.serverEndpointKey), + ) } @Test From ea0950911373473eea12474620473115bc675dc9 Mon Sep 17 00:00:00 2001 From: Jasmin Le Roux Date: Wed, 8 Jul 2026 17:48:22 +0200 Subject: [PATCH 14/22] Document visual QA device blocker --- .tickets/oa-5sro.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.tickets/oa-5sro.md b/.tickets/oa-5sro.md index ae66a361..40716888 100644 --- a/.tickets/oa-5sro.md +++ b/.tickets/oa-5sro.md @@ -25,3 +25,5 @@ Before shipping, perform staged verification and visual QA for the pinned Home + - 2026-07-08: `JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew :app:compileDebugKotlin && JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew :app:detekt && JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew :app:testDebugUnitTest` passed. - 2026-07-08: Manual/visual QA is blocked because `adb devices` returned no connected emulator/device. Do not close until screenshots cover the required 11 scenarios. +- 2026-07-08: `emulator -list-avds` found `Pixel7`, but booting it failed because x86_64 emulation requires hardware acceleration and `/dev/kvm` is unavailable. `adb wait-for-device` was cancelled after the emulator failure. Visual QA remains blocked until a physical device or KVM-capable emulator is available. +- Planned screenshot walkthrough once device is available: first connect; restore existing chat; Home server carousel; workspace drill-in; + from active chat; + from Home workspace detail; browse sessions; server auth failure; server removal with open tabs; app background/reconnect; Home vs + distinction. From 865d1e37d9afb47464e8b16ac3e8921aa6b87ddb Mon Sep 17 00:00:00 2001 From: Jasmin Le Roux Date: Thu, 9 Jul 2026 10:21:36 +0200 Subject: [PATCH 15/22] Complete Home architecture visual QA --- .tickets/oa-5sro.md | 16 +++++- .tickets/oa-xju6.md | 2 +- app/detekt-baseline.xml | 5 +- .../p4oc/ui/screens/server/ServerScreen.kt | 11 +++- .../blazelight/p4oc/ui/tabs/MainTabScreen.kt | 55 ++++++++++++++++++- .../dev/blazelight/p4oc/ui/tabs/TabNavHost.kt | 20 ++++++- .../dev/blazelight/p4oc/ui/tabs/TabState.kt | 2 +- 7 files changed, 98 insertions(+), 13 deletions(-) diff --git a/.tickets/oa-5sro.md b/.tickets/oa-5sro.md index 40716888..1d9e6fb0 100644 --- a/.tickets/oa-5sro.md +++ b/.tickets/oa-5sro.md @@ -1,6 +1,6 @@ --- id: oa-5sro -status: open +status: closed deps: [oa-6swf, oa-yx4y, oa-07lr, oa-fac4, oa-nugm, oa-pjcl, oa-1xnu, oa-cj0w] links: [] created: 2026-07-08T14:42:17Z @@ -27,3 +27,17 @@ Before shipping, perform staged verification and visual QA for the pinned Home + - 2026-07-08: Manual/visual QA is blocked because `adb devices` returned no connected emulator/device. Do not close until screenshots cover the required 11 scenarios. - 2026-07-08: `emulator -list-avds` found `Pixel7`, but booting it failed because x86_64 emulation requires hardware acceleration and `/dev/kvm` is unavailable. `adb wait-for-device` was cancelled after the emulator failure. Visual QA remains blocked until a physical device or KVM-capable emulator is available. - Planned screenshot walkthrough once device is available: first connect; restore existing chat; Home server carousel; workspace drill-in; + from active chat; + from Home workspace detail; browse sessions; server auth failure; server removal with open tabs; app background/reconnect; Home vs + distinction. +- 2026-07-09: Device visual QA completed on `192.168.24.119:47293` after installing the current debug build. Screenshot evidence is intentionally kept uncommitted under `local-adb-screenshots/oa-5sro-visual-qa/`: + - First connect: `01_first_connect_server_screen.png`, `02_first_connect_retry.png`, `02_first_connect_home_server_summary.png`. + - Restore existing chat: `10_restore_existing_chat.png`. + - Home server carousel / saved server summary: `02_first_connect_home_server_summary.png` and `12_home_server_workspace_open_work.png`. + - Workspace drill-in: `13_workspace_drill_in.png`. + - `+` from active chat: `11_plus_from_active_chat.png`. + - `+` from Home workspace detail: `14_plus_from_workspace_detail.png`. + - Browse sessions: `15_browse_sessions.png` and `tmp_sessions_for_disconnect.png`. + - Server auth failure: `02_first_connect_home_server_carousel.png` captured the unauthorized state before retrying with the verified password. + - Server removal with open tabs: `17_server_removal_open_tabs_warning.png` shows saved server `Remote Server` with `warn remove` while an open tab references that endpoint. + - App background/reconnect: `16_background_reconnect.png`. + - Home = existing work versus `+` = new work distinction: `07_connected_home.png`, `08_plus_from_home_start_work.png`, and `09_new_chat_from_home.png`. +- 2026-07-09: QA server/auth verified before visual pass: unauthenticated `GET /config` returned `401`, authenticated `opencode:hunter2` returned `200`, and device TCP reachability to `192.168.24.25:4096` succeeded. +- 2026-07-09: Final verification passed: `JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew :app:compileDebugKotlin && JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew :app:detekt && JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew :app:testDebugUnitTest`. diff --git a/.tickets/oa-xju6.md b/.tickets/oa-xju6.md index bbd6d730..c6af9b0a 100644 --- a/.tickets/oa-xju6.md +++ b/.tickets/oa-xju6.md @@ -1,6 +1,6 @@ --- id: oa-xju6 -status: open +status: closed deps: [] links: [] created: 2026-07-08T14:42:17Z diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml index 6be02640..461c009d 100644 --- a/app/detekt-baseline.xml +++ b/app/detekt-baseline.xml @@ -159,7 +159,7 @@ FunctionNaming:ServerScreen.kt$@Composable private fun DiscoveredServersSection( servers: List<DiscoveredServer>, discoveryState: DiscoveryState, isConnecting: Boolean, onServerClick: (DiscoveredServer) -> Unit ) FunctionNaming:ServerScreen.kt$@Composable private fun RecentServersSection( servers: List<RecentServer>, isConnecting: Boolean, onServerClick: (RecentServer) -> Unit, onRemoveServer: (RecentServer) -> Unit ) FunctionNaming:ServerScreen.kt$@Composable private fun RemoteServerSection( url: String, username: String, password: String, allowInsecure: Boolean, isConnecting: Boolean, onUrlChange: (String) -> Unit, onUsernameChange: (String) -> Unit, onPasswordChange: (String) -> Unit, onAllowInsecureChange: (Boolean) -> Unit, onConnect: () -> Unit ) - FunctionNaming:ServerScreen.kt$@Composable private fun SavedServersSection( servers: List<SavedServer>, isConnecting: Boolean, hasOpenTabs: Boolean, onServerClick: (SavedServer) -> Unit, onRemoveServer: (SavedServer) -> Unit, ) + FunctionNaming:ServerScreen.kt$@Composable private fun SavedServersSection( servers: List<SavedServer>, isConnecting: Boolean, openTabEndpointKeys: Set<String>, onServerClick: (SavedServer) -> Unit, onRemoveServer: (SavedServer) -> Unit, ) FunctionNaming:ServerScreen.kt$@Composable private fun ScanningIndicator() FunctionNaming:ServerScreen.kt$@Composable private fun ServerSetupHelpSection() FunctionNaming:ServerScreen.kt$@Composable private fun SetupCodeBlock(command: String) @@ -274,7 +274,6 @@ ImportOrdering:ConnectionManager.kt$import dev.blazelight.p4oc.BuildConfig import dev.blazelight.p4oc.core.datastore.SettingsDataStore import dev.blazelight.p4oc.core.log.AppLog import dev.blazelight.p4oc.data.remote.dto.ProjectDto import dev.blazelight.p4oc.data.remote.mapper.EventMapper import dev.blazelight.p4oc.domain.server.ScopedEvent import dev.blazelight.p4oc.domain.server.ServerGeneration import dev.blazelight.p4oc.domain.server.ServerRef import dev.blazelight.p4oc.domain.server.WorkspaceKey import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import kotlinx.serialization.json.Json import okhttp3.ConnectionPool import okhttp3.Credentials import okhttp3.Dispatcher import okhttp3.Interceptor import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit import retrofit2.converter.kotlinx.serialization.asConverterFactory import java.util.concurrent.TimeUnit ImportOrdering:HomeScreen.kt$import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Terminal import androidx.compose.material.icons.filled.ViewList import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.platform.testTag import dev.blazelight.p4oc.ui.theme.LocalOpenCodeTheme import dev.blazelight.p4oc.domain.server.WorkspaceKey import dev.blazelight.p4oc.ui.theme.Spacing import dev.blazelight.p4oc.ui.theme.TuiShapes ImportOrdering:SettingsDataStore.kt$import android.content.Context import androidx.datastore.core.DataMigration import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.* import androidx.datastore.preferences.preferencesDataStore import dev.blazelight.p4oc.core.log.AppLog import dev.blazelight.p4oc.core.security.CredentialStore import dev.blazelight.p4oc.core.network.ServerUrl import dev.blazelight.p4oc.data.remote.dto.ModelInput import dev.blazelight.p4oc.domain.server.WorkspaceKey import dev.blazelight.p4oc.domain.session.SessionId import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json - ImportOrdering:TabNavHost.kt$import android.net.Uri import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.compose.animation.* import androidx.compose.animation.core.tween import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import androidx.navigation.NavBackStackEntry import androidx.navigation.NavHostController import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.navigation import androidx.navigation.navArgument import dev.blazelight.p4oc.core.datastore.SettingsDataStore import dev.blazelight.p4oc.core.datastore.VisualSettings import dev.blazelight.p4oc.domain.model.SessionConnectionState import dev.blazelight.p4oc.domain.server.WorkspaceKey import dev.blazelight.p4oc.domain.server.ServerRef import dev.blazelight.p4oc.ui.navigation.Screen import dev.blazelight.p4oc.ui.screens.chat.ChatScreen import dev.blazelight.p4oc.ui.screens.diff.DiffViewerScreen import dev.blazelight.p4oc.ui.screens.diff.SessionDiffScreen import dev.blazelight.p4oc.ui.screens.files.FileExplorerScreen import dev.blazelight.p4oc.ui.screens.files.FileViewerScreen import dev.blazelight.p4oc.ui.screens.files.FilesViewModel import dev.blazelight.p4oc.ui.screens.home.HomeScreen import dev.blazelight.p4oc.ui.screens.home.HomeSummaryBuilder import dev.blazelight.p4oc.ui.screens.projects.ProjectsScreen import dev.blazelight.p4oc.ui.screens.sessions.SessionListScreen import dev.blazelight.p4oc.ui.screens.sessions.SessionListViewModel import dev.blazelight.p4oc.ui.screens.settings.* import dev.blazelight.p4oc.ui.screens.terminal.TerminalScreen import dev.blazelight.p4oc.ui.workspace.WorkspaceRepositoryOwner import dev.blazelight.p4oc.ui.workspace.WorkspaceViewModel import org.koin.androidx.compose.koinViewModel import org.koin.compose.koinInject import org.koin.core.parameter.parametersOf Indentation:ChatScreen.kt$ InstanceOfCheckForException:DialogQueueManager.kt$DialogQueueManager$e is CancellationException LargeClass:SessionRepositoryImpl.kt$SessionRepositoryImpl : SessionRepository @@ -327,7 +326,7 @@ LongMethod:ProviderConfigScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ProviderConfigScreen( viewModel: ProviderConfigViewModel = koinViewModel(), onNavigateBack: () -> Unit ) LongMethod:ServerScreen.kt$@Composable private fun DiscoveredServersSection( servers: List<DiscoveredServer>, discoveryState: DiscoveryState, isConnecting: Boolean, onServerClick: (DiscoveredServer) -> Unit ) LongMethod:ServerScreen.kt$@Composable private fun RemoteServerSection( url: String, username: String, password: String, allowInsecure: Boolean, isConnecting: Boolean, onUrlChange: (String) -> Unit, onUsernameChange: (String) -> Unit, onPasswordChange: (String) -> Unit, onAllowInsecureChange: (Boolean) -> Unit, onConnect: () -> Unit ) - LongMethod:ServerScreen.kt$@Composable private fun SavedServersSection( servers: List<SavedServer>, isConnecting: Boolean, hasOpenTabs: Boolean, onServerClick: (SavedServer) -> Unit, onRemoveServer: (SavedServer) -> Unit, ) + LongMethod:ServerScreen.kt$@Composable private fun SavedServersSection( servers: List<SavedServer>, isConnecting: Boolean, openTabEndpointKeys: Set<String>, onServerClick: (SavedServer) -> Unit, onRemoveServer: (SavedServer) -> Unit, ) LongMethod:ServerScreen.kt$@Composable private fun ServerSetupHelpSection() LongMethod:ServerScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ServerScreen( viewModel: ServerViewModel = koinViewModel(), onNavigateToSessions: () -> Unit, onNavigateToProjects: () -> Unit, onSettings: () -> Unit ) LongMethod:ServerViewModel.kt$ServerViewModel$fun connectToRemote() diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerScreen.kt index b4bb92e8..3dc05b19 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerScreen.kt @@ -35,10 +35,12 @@ import dev.blazelight.p4oc.core.datastore.SavedServer import dev.blazelight.p4oc.core.network.DiscoveredServer import dev.blazelight.p4oc.core.network.DiscoveryState import dev.blazelight.p4oc.ui.components.TuiLoadingIndicator +import dev.blazelight.p4oc.ui.tabs.TabManager import dev.blazelight.p4oc.ui.theme.LocalOpenCodeTheme import dev.blazelight.p4oc.ui.theme.Sizing import dev.blazelight.p4oc.ui.theme.Spacing import org.koin.androidx.compose.koinViewModel +import org.koin.compose.koinInject @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -50,6 +52,9 @@ fun ServerScreen( ) { val theme = LocalOpenCodeTheme.current val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val tabManager: TabManager = koinInject() + val tabs by tabManager.tabs.collectAsState() + val openTabEndpointKeys = tabs.mapNotNull { it.serverEndpointKey }.toSet() // Start/stop mDNS discovery with screen lifecycle DisposableEffect(Unit) { @@ -125,7 +130,7 @@ fun ServerScreen( SavedServersSection( servers = uiState.savedServers, isConnecting = uiState.isConnecting, - hasOpenTabs = false, + openTabEndpointKeys = openTabEndpointKeys, onServerClick = { saved -> viewModel.setRemoteUrl(saved.endpoint) viewModel.setUsername(saved.username ?: "opencode") @@ -495,7 +500,7 @@ private fun SetupCodeBlock(command: String) { private fun SavedServersSection( servers: List, isConnecting: Boolean, - hasOpenTabs: Boolean, + openTabEndpointKeys: Set, onServerClick: (SavedServer) -> Unit, onRemoveServer: (SavedServer) -> Unit, ) { @@ -550,7 +555,7 @@ private fun SavedServersSection( ) } Text( - text = if (hasOpenTabs) "warn remove" else "remove", + text = if (server.endpointKey in openTabEndpointKeys) "warn remove" else "remove", color = theme.warning, fontFamily = FontFamily.Monospace, modifier = Modifier.clickable(role = Role.Button) { onRemoveServer(server) }, diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/MainTabScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/MainTabScreen.kt index d29d1ab9..464320e7 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/MainTabScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/MainTabScreen.kt @@ -38,6 +38,8 @@ import dev.blazelight.p4oc.domain.workspace.Workspace import dev.blazelight.p4oc.ui.components.TuiAlertDialog import dev.blazelight.p4oc.ui.components.TuiTextButton import dev.blazelight.p4oc.ui.navigation.Screen +import dev.blazelight.p4oc.ui.screens.home.HomeScreen +import dev.blazelight.p4oc.ui.screens.home.HomeSummaryBuilder import dev.blazelight.p4oc.ui.theme.LocalOpenCodeTheme import dev.blazelight.p4oc.ui.theme.Spacing import dev.blazelight.p4oc.ui.theme.TuiShapes @@ -71,6 +73,16 @@ fun MainTabScreen( val currentServerRef = remember(connectionManager.currentBaseUrl) { connectionManager.currentBaseUrl?.let { ServerRef.fromEndpoint(it) } } + val savedServers by settingsDataStore.savedServers.collectAsState(initial = emptyList()) + val homeConnectionStates = remember(savedServers, connectionState, currentServerRef?.endpointKey) { + savedServers.associate { savedServer -> + savedServer.endpointKey to if (savedServer.endpointKey == currentServerRef?.endpointKey) { + connectionState + } else { + ConnectionState.Disconnected + } + } + } var wasEverConnected by remember { mutableStateOf(false) } @@ -406,7 +418,48 @@ fun MainTabScreen( val isActive = tab.id == activeTabId val workspaceOwner = workspaceOwners[tab.id] - if (workspaceOwner != null) { + if (tab.isPinnedHome) { + HomeScreen( + summary = HomeSummaryBuilder.build( + savedServers = savedServers, + connectionStates = homeConnectionStates, + tabs = tabs, + ), + onBrowseSessions = { + tabManager.createTab( + startRoute = Screen.Sessions.route, + workspaceKey = WorkspaceKey.Global, + serverRef = currentServerRef ?: return@HomeScreen, + focus = true, + ) + }, + onOpenFiles = { requestFilesTab(WorkspaceKey.Global) }, + onOpenTerminal = { + coroutineScope.launch { + val api = connectionManager.getApi() ?: run { + snackbarHostState.showSnackbar("Not connected to server") + return@launch + } + val result = safeApiCall { + api.createPtySession(createPtyRequestForWorkspace(WorkspaceKey.Global)) + } + if (result is ApiResult.Success) { + tabManager.createTab( + startRoute = Screen.Terminal.createRoute(result.data.id), + workspaceKey = WorkspaceKey.Global, + serverRef = currentServerRef ?: return@launch, + focus = true, + ) + } else if (result is ApiResult.Error) { + snackbarHostState.showSnackbar( + "Failed to create terminal: ${result.message}", + ) + } + } + }, + modifier = Modifier.fillMaxSize(), + ) + } else if (workspaceOwner != null) { TabNavHost( navController = navController, tabManager = tabManager, diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabNavHost.kt b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabNavHost.kt index 9714bf5a..0ba36337 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabNavHost.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabNavHost.kt @@ -23,9 +23,11 @@ import androidx.navigation.compose.navigation import androidx.navigation.navArgument import dev.blazelight.p4oc.core.datastore.SettingsDataStore import dev.blazelight.p4oc.core.datastore.VisualSettings +import dev.blazelight.p4oc.core.network.ConnectionManager +import dev.blazelight.p4oc.core.network.ConnectionState import dev.blazelight.p4oc.domain.model.SessionConnectionState -import dev.blazelight.p4oc.domain.server.WorkspaceKey import dev.blazelight.p4oc.domain.server.ServerRef +import dev.blazelight.p4oc.domain.server.WorkspaceKey import dev.blazelight.p4oc.ui.navigation.Screen import dev.blazelight.p4oc.ui.screens.chat.ChatScreen import dev.blazelight.p4oc.ui.screens.diff.DiffViewerScreen @@ -83,6 +85,18 @@ fun TabNavHost( // Read visual settings for sub-agent tab behavior val settingsDataStore: SettingsDataStore = koinInject() val visualSettings by settingsDataStore.visualSettings.collectAsState(initial = VisualSettings()) + val savedServers by settingsDataStore.savedServers.collectAsState(initial = emptyList()) + val connectionManager: ConnectionManager = koinInject() + val connectionState by connectionManager.connectionState.collectAsState() + val homeConnectionStates = remember(savedServers, connectionState, serverRef.endpointKey) { + savedServers.associate { savedServer -> + savedServer.endpointKey to if (savedServer.endpointKey == serverRef.endpointKey) { + connectionState + } else { + ConnectionState.Disconnected + } + } + } val openSubAgentInNewTab = visualSettings.openSubAgentInNewTab val tabs by tabManager.tabs.collectAsState() val tab = tabs.firstOrNull { it.id == tabId } @@ -161,8 +175,8 @@ fun TabNavHost( composable(Screen.Home.route) { HomeScreen( summary = HomeSummaryBuilder.build( - savedServers = emptyList(), - connectionStates = emptyMap(), + savedServers = savedServers, + connectionStates = homeConnectionStates, tabs = tabs, ), onBrowseSessions = { navController.navigate(Screen.Sessions.route) }, diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabState.kt b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabState.kt index 04e31880..1ffe2cc2 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabState.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabState.kt @@ -103,7 +103,7 @@ class TabInstance( const val HOME_TAB_ID = "pinned-home" fun home(): TabInstance = TabInstance( - TabState(id = HOME_TAB_ID, pinnedHome = true), + TabState(id = HOME_TAB_ID, workspaceKey = WorkspaceKey.Global, pinnedHome = true), startRoute = Screen.Home.route, ) } From 9f9306641349607e45bfb6e69e6fc85cf2c52bda Mon Sep 17 00:00:00 2001 From: Jasmin Le Roux Date: Thu, 9 Jul 2026 17:26:22 +0200 Subject: [PATCH 16/22] Track UX review follow-ups --- .tickets/oa-6uju.md | 56 +++++++ .tickets/oa-runv.md | 75 ++++++++++ .tickets/oa-xyj0.md | 65 +++++++++ .tickets/oa-yujn.md | 69 +++++++++ .tickets/oa-zemb.md | 68 +++++++++ .../p4oc/ui/screens/server/ServerScreen.kt | 137 +++++++++++++----- app/src/main/res/values/strings.xml | 12 ++ 7 files changed, 447 insertions(+), 35 deletions(-) create mode 100644 .tickets/oa-6uju.md create mode 100644 .tickets/oa-runv.md create mode 100644 .tickets/oa-xyj0.md create mode 100644 .tickets/oa-yujn.md create mode 100644 .tickets/oa-zemb.md diff --git a/.tickets/oa-6uju.md b/.tickets/oa-6uju.md new file mode 100644 index 00000000..cbc2bb97 --- /dev/null +++ b/.tickets/oa-6uju.md @@ -0,0 +1,56 @@ +--- +id: oa-6uju +status: closed +deps: [] +links: [] +created: 2026-07-09T00:00:00Z +type: task +priority: 1 +assignee: Jasmin Le Roux +--- +# Fix release visual QA guide evidence honesty + +## Problem + +The release visual QA guide at `local-adb-screenshots/release-v013-to-current/index.html` contradicts itself: several scenarios show red `Missing ...` placeholders while their Result text says `captured before/after`. Reviewers cannot trust the guide if the visible evidence status conflicts with the summary. + +Concrete known contradictions: + +- `Draft persistence across tab switch`: missing `after/055_current_chat_draft_persistence.png` but says captured before/after. +- `Chat scroll restoration`: missing `after/056_current_chat_scroll_position.png` but says captured before/after. +- `Rotation and ViewModel recreation`: missing `after/057_current_chat_rotation_landscape.png` but says captured before/after. +- `Portrait state after rotation`: missing `after/058_current_chat_rotation_portrait.png` but says captured before/after. +- `Upgrade tab survival`: missing `before/023_old_pre_upgrade_visible_tab_bar.png`; likely stale filename because `before/23_old_pre_upgrade_visible_tab_bar.png` exists. +- `Provider/model settings`: missing `before/030_provider_model_settings_old.png` but says captured before/after. + +## Known consistency note + +`oa-5sro` and `oa-xju6` were closed before this guide audit, and their closeout record may overstate the reliability of the release visual QA guide. Do not silently rewrite that history or reopen those tickets unless explicitly requested. Instead, this ticket is the follow-up record that reconciles the overstated evidence claims and links the correction back to the closed QA/architecture work. + +## UX Constraint + +The report must be an honest QA artifact, not commentary that explains away missing screenshots. If a screenshot does not exist or does not prove the scenario, say so plainly. + +## Expected Behavior + +Each scenario reports its actual evidence state: + +- before image present/missing +- after image present/missing +- behavior visually proven / partially proven / not visually proven +- test/API evidence if visual proof is intentionally unavailable +- blocked reason when evidence is missing + +## Acceptance Criteria + +- No scenario says `captured before/after` when either displayed image is missing. +- Missing image placeholders are either eliminated by recapturing/renaming screenshots or paired with `partial`, `before-only`, `after-only`, or `missing current evidence` result text. +- Stale filename references are fixed, including `023_old_pre_upgrade_visible_tab_bar.png` if the actual file is `23_old_pre_upgrade_visible_tab_bar.png`. +- The guide distinguishes visual proof from reachability/menu screenshots and test/API evidence. +- Screenshot-count-gate language is removed or moved out of the user-facing report. + +## Verification + +- Search the generated guide for `Missing` and verify nearby Result text does not overclaim. +- Verify every referenced image exists or is intentionally labeled missing/partial. +- Open the guide in Firefox and visually inspect the corrected scenarios. diff --git a/.tickets/oa-runv.md b/.tickets/oa-runv.md new file mode 100644 index 00000000..405940e7 --- /dev/null +++ b/.tickets/oa-runv.md @@ -0,0 +1,75 @@ +--- +id: oa-runv +status: open +deps: [] +links: [] +created: 2026-07-09T15:15:06Z +type: task +priority: 2 +assignee: Jasmin Le Roux +--- +# Rewrite Home and Start Work user copy + +## Problem + +The pinned Home and Start Work model is conceptually right, but current copy explains implementation details instead of helping users decide what to tap. This wastes scarce phone space and weakens the intended mental model. + +## Evidence / Repro + +Current Home screenshots such as `local-adb-screenshots/oa-5sro-visual-qa/07_connected_home.png` and `12_home_server_workspace_open_work.png` show internal wording like: + +- `0 bounded open work items; 0 workspace summaries loaded without chat history.` +- `1 bounded open work item; 1 workspace summary loaded without chat history.` +- `Attention appears as compact badges and dots on Home, tabs, servers, and workspaces — not as a feed.` + +The Start Work sheet screenshot `local-adb-screenshots/oa-5sro-visual-qa/08_plus_from_home_start_work.png` has the right actions but uses vague/contextual copy such as: + +- `Choose an action. Current server/workspace will be explicit before creation.` + +Reviewers found this reads like QA/developer explanation, not a user-facing launcher. + +## UX Constraints + +- Home means open existing work, resume sessions, and browse workspaces. +- `+` means create/start new work in the current or explicitly selected context. +- Notifications/attention should remain compact badges/dots, not Home feed content or explanatory cards. +- Follow AGENTS.md Agent-Space UI Rule: UI chrome must justify itself by helping work; prefer contextual/transient information over persistent explanation. + +## Expected Behavior + +Home should tell users what they can do next without exposing data-loading internals. Suggested direction: + +- Empty open-work copy: `No open work yet` / `Resume a session or start new work with +.` +- Existing work copy: `1 open item` / `Resume files, terminals, or chats from this workspace.` +- Saved server copy: `Remote Server · connected` / `1 open item`. +- Remove the persistent Attention explanation unless there is actionable attention. + +Start Work should make target explicit: + +- From an active workspace: `Target: Remote Server · p4oc-alpha`. +- From Home/global: `Choose a target before creating work` or `Target: Remote Server · Global` if a default is known. +- Rows should use short action labels and user outcomes, not implementation notes. + +## Acceptance Criteria + +- Home no longer shows `bounded`, `workspace summaries loaded`, `without chat history`, or other implementation/data-loader phrasing. +- Empty and populated Home states use task-oriented copy. +- The persistent Attention explanatory card is removed, collapsed, or only shown when there is real actionable attention. +- Start Work sheet always names the target or clearly asks the user to choose one before creation. +- Home still emphasizes existing/resume/browse; `+` still emphasizes create/start. +- Copy is localized in `strings.xml` where applicable. +- Screenshots of Home empty, Home with open work, Start Work from Home, and Start Work from active workspace show the intended mental model without commentary. +- Compile and detekt pass. + +## Failure States To Avoid + +- Do not turn Home into a notification feed. +- Do not duplicate every `+` creation action as a prominent Home action; Home can provide secondary start affordances but must primarily open existing work. +- Do not hide Browse Sessions; Sessions remains the search/history/actions surface. +- Do not use vague target copy that makes users wonder which server/workspace will be affected. + +## Verification + +- Inspect Home and Start Work screenshots before/after the copy change. +- Grep source for banned internal phrases: `bounded`, `without chat history`, `workspace summaries loaded`, `Current server/workspace will be explicit`. +- Run `JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew :app:compileDebugKotlin && JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew :app:detekt`. diff --git a/.tickets/oa-xyj0.md b/.tickets/oa-xyj0.md new file mode 100644 index 00000000..6f92362c --- /dev/null +++ b/.tickets/oa-xyj0.md @@ -0,0 +1,65 @@ +--- +id: oa-xyj0 +status: open +deps: [] +links: [] +created: 2026-07-09T15:15:06Z +type: task +priority: 2 +assignee: Jasmin Le Roux +--- +# Redesign Server screen hierarchy + +## Problem + +`ServerScreen` currently reads like a debug/admin control panel instead of a safe mobile server connection flow. It renders every server concept at once in one vertical scroll: Discovered Servers, Saved servers, Recent Servers, Remote Server form, errors, and Server Setup. There is no progressive disclosure, no clear primary path, and duplicate server rows appear in multiple sections. + +## Evidence / Repro + +- `app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerScreen.kt` renders the main panel order around lines 119-190 as: Discovered -> Saved -> Recent -> Remote form -> error -> Help. +- `local-adb-screenshots/oa-5sro-visual-qa/17_server_removal_open_tabs_warning.png` shows Discovered Servers, Saved servers, Recent Servers, Remote Server, and Server Setup all competing above/below the fold. +- The same endpoint can appear as discovered (`opencode-4096`), saved (`Remote Server`), and recent (`Remote Server`), making the screen look duplicated rather than structured. +- Visual review called this a "wall of monospace" because all panels are same-weight TUI boxes with dense endpoint text and inline actions. + +## UX Constraints + +- Follow the AGENTS.md Agent-Space UI Rule: prefer contextual, transient, collapsible, or overflow UI over persistent chrome; row-specific actions belong in long-press or overflow menus. +- Server management owns add/edit/remove/auth/reconnect/discovery/certificate policy. Home should only summarize server status where it affects work. +- First-run users need a clear primary action; returning users need fast saved-server selection and safe management. + +## Expected Behavior + +Split the surface into understandable tasks: + +1. **Connect to a server**: primary first-run/disconnected path. Prioritize discovered/saved target if available, otherwise manual URL. +2. **Saved servers / Manage servers**: saved connection targets with friendly names, endpoint, auth/TLS state, connection state, and open-tab count. +3. **Nearby / Discovered**: nearby servers as suggestions, not equal-weight admin panels. +4. **Manual URL**: available but not dominating when saved/discovered targets exist. +5. **Help / Setup**: collapsed by default. + +Row tap should connect/select. Secondary actions should move to overflow or a dedicated management/detail sheet. + +## Acceptance Criteria + +- The top of the screen has one obvious primary connection path. +- Saved, recent, and discovered servers are not shown as confusing duplicate same-weight panels. +- Manual URL remains available but does not dominate when saved/discovered targets exist. +- Server Setup / help content is collapsed or visually secondary. +- Server rows show stable fields: display name, endpoint, connection/auth/TLS status, and open-tab count when relevant. +- Row-specific actions are not always-visible inline text; use overflow/long-press/detail. +- Empty, first-run, saved-server, discovered-server, auth-failure, and open-tabs states are visually distinct. +- Strings are localized and functional controls have content descriptions/test tags. +- Compile and detekt pass. + +## Failure States To Avoid + +- Do not hide manual URL entirely; users still need explicit server entry. +- Do not make server switching a global mode that loses tab identity; tabs can span servers. +- Do not put destructive actions in the primary tap target. +- Do not add persistent server chrome to Home unless it directly helps work selection. + +## Verification + +- Capture screenshots for first-run, saved server, discovered server, auth failure, and open-tabs removal-warning states. +- Verify a reviewer can identify the primary action without reading implementation notes. +- Run `JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew :app:compileDebugKotlin && JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew :app:detekt`. diff --git a/.tickets/oa-yujn.md b/.tickets/oa-yujn.md new file mode 100644 index 00000000..1c066520 --- /dev/null +++ b/.tickets/oa-yujn.md @@ -0,0 +1,69 @@ +--- +id: oa-yujn +status: open +deps: [] +links: [] +created: 2026-07-09T15:15:06Z +type: task +priority: 2 +assignee: Jasmin Le Roux +--- +# Centralize Server screen status glyphs + +## Problem + +`ServerScreen` scatters raw text glyphs and status/action symbols throughout the UI. This makes statuses ambiguous, inaccessible, hard to localize, and inconsistent with the project status-dot semantics. + +## Evidence / Repro + +Known raw glyph/action usages in `app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerScreen.kt` include: + +- `⚙` settings affordance around line 97. +- `✗` clear/remove affordance around line 177. +- `◉` / `○` server type selection around lines 295-301. +- `[ ]` / checked-style text for TLS toggle around lines 314-318. +- `●` saved/discovered status dots around lines 538 and 692. +- `◇` recent server marker around line 603. +- `×` recent server remove action around line 626. +- `→` connect/open affordances around lines 715 and elsewhere. +- `● scanning` status around line 740. +- Raw `remove` / `warn remove` overlaps with oa-zemb. + +These appear in screenshots such as `local-adb-screenshots/oa-5sro-visual-qa/17_server_removal_open_tabs_warning.png`, where users see multiple symbols without clear semantic distinction. + +## UX Constraints + +- Follow AGENTS.md Status Dot Semantics: use one consistent status language across tabs, sessions, sub-agents, chat, files, and settings; prefer centralized mappings over scattered raw `Text("●")` glyphs. +- Functional indicators need meaningful content descriptions and should not rely on shape/text glyph alone. +- Use project theme tokens, `LocalOpenCodeTheme`, `Spacing`, `Sizing`, TUI components, and resource-backed strings. + +## Expected Behavior + +Server screen status and action indicators come from centralized components/mappings rather than ad hoc `Text` glyphs. Examples: + +- `ServerStatusIndicator` or equivalent for connected/disconnected/discovered/scanning/error/open-tabs states. +- `TuiIconButton`/`IconButton` with localized content descriptions for settings, clear, remove, overflow, connect. +- A row overflow/action component for secondary row actions. +- Explicit text labels where symbols are ambiguous. + +## Acceptance Criteria + +- No functional ServerScreen control/status is represented only by raw `Text("●")`, `Text("◇")`, `Text("×")`, `Text("→")`, `Text("⚙")`, `Text("✗")`, or bracket checkbox text. +- Status meanings are centralized in one mapping/component with named severities/states. +- Functional controls have localized content descriptions and test tags where they are key interactions. +- The visual status language matches AGENTS.md semantics: stable/connected, idle, running, awaiting input/warning, retrying, error, background/coldmuted, dirty. +- Decorative glyphs, if any remain, are not the only semantic carrier. +- Strings are resource-backed. +- Compile and detekt pass. + +## Failure States To Avoid + +- Do not merely wrap raw glyphs in helper functions without assigning semantics. +- Do not replace every glyph with heavy Material icons if that breaks the TUI density; use compact themed indicators. +- Do not introduce multiple competing status languages for Home, tabs, and server rows. + +## Verification + +- Grep `ServerScreen.kt` for raw glyph text and confirm functional usages are gone or decorative-only. +- Inspect screenshots for saved/recent/discovered/scan/auth-failure states and confirm status meanings are understandable. +- Run `JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew :app:compileDebugKotlin && JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew :app:detekt`. diff --git a/.tickets/oa-zemb.md b/.tickets/oa-zemb.md new file mode 100644 index 00000000..85e5e084 --- /dev/null +++ b/.tickets/oa-zemb.md @@ -0,0 +1,68 @@ +--- +id: oa-zemb +status: open +deps: [] +links: [] +created: 2026-07-09T00:00:00Z +type: task +priority: 1 +assignee: Jasmin Le Roux +--- +# Make saved server removal safe + +## Problem + +`ServerScreen` renders destructive saved-server removal as inline raw text: `remove` or `warn remove`. Tapping it immediately removes the saved server. When open tabs reference that server, `warn remove` looks like a status label rather than a destructive action, and it does not explain what happens to those tabs. + +This violates the project UI rule that row-specific actions belong in long-press or overflow menus and creates data-loss/footgun risk. + +## Evidence + +- `app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerScreen.kt` currently renders `text = if (server.endpointKey in openTabEndpointKeys) "warn remove" else "remove"` in the saved server row. +- `local-adb-screenshots/oa-5sro-visual-qa/17_server_removal_open_tabs_warning.png` shows `warn remove` inline next to `Remote Server`. +- AGENTS.md Agent-Space UI Rule prefers row-specific actions in long-press or overflow menus, not persistent inline destructive text. +- The same screen already has open-tab endpoint awareness; use that count to explain consequences before removal. + +## UX Constraint + +Row tap should select/connect to a server. Destructive actions must be secondary, explicit, localized, and confirmed when open tabs are affected. + +## Expected Behavior + +Saved server rows expose an overflow action affordance with accessible semantics. The destructive item is named `Forget server`, appears last, and opens a confirmation dialog when open tabs reference that server. +- Keep server row tap reserved for connect/select. Do not overload it with destructive management. +- Follow Agent-Space UI Rule: row-specific actions should be contextual/overflow, not always-visible inline chrome. + +Open-tab confirmation copy should explain the consequence, for example: + +`1 open tab is using this server. Existing tabs will stay open until closed or reconnected, but this server will be removed from saved targets.` + +## Acceptance Criteria + +- Inline `remove` / `warn remove` text is gone from saved server rows. +- Saved server rows have an overflow/menu action with a content description and test tag. +- The overflow menu includes `Forget server` as a destructive action. +- Forgetting a saved server with open tabs shows a confirmation dialog with the open-tab count and consequence. +- Forgetting a saved server without open tabs is still explicit and not triggered by tapping the row body. +- Visible strings are localized in `strings.xml`. +- Key interactions have content descriptions/test tags. +- Compile and detekt pass. + +## Failure States To Avoid + +- Do not silently delete a saved server from a warning-colored inline label. +- Do not imply open tabs will be closed if they will remain open, or imply they remain safe if removal will break reconnect. +- Do not leave `warn remove` or `remove` as raw visible strings in code. +- Do not move the destructive action into the row body tap target. + +## Verification + +- Capture or inspect the saved server row: no inline `warn remove` should appear. +- With an open tab for a server, choose Forget server and verify confirmation appears before removal. +- Run `JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew :app:compileDebugKotlin && JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew :app:detekt`. + +## Verification Notes + +- 2026-07-09: Implementation changed saved server rows from inline `remove` / `warn remove` text to an overflow menu with localized `Forget server` and a destructive `TuiConfirmDialog` that includes open-tab count when applicable. +- 2026-07-09: `JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew :app:compileDebugKotlin && JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew :app:detekt` passed. +- 2026-07-09: Device verification is still required before closing. `JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew :app:installDebug` failed with `No connected devices`, and `adb devices` returned no connected device. Do not close until the overflow -> Forget server -> confirmation dialog flow is screenshotted with an open tab referencing the saved server. diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerScreen.kt index 3dc05b19..1f40af4f 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* @@ -34,6 +35,7 @@ import dev.blazelight.p4oc.core.datastore.RecentServer import dev.blazelight.p4oc.core.datastore.SavedServer import dev.blazelight.p4oc.core.network.DiscoveredServer import dev.blazelight.p4oc.core.network.DiscoveryState +import dev.blazelight.p4oc.ui.components.TuiConfirmDialog import dev.blazelight.p4oc.ui.components.TuiLoadingIndicator import dev.blazelight.p4oc.ui.tabs.TabManager import dev.blazelight.p4oc.ui.theme.LocalOpenCodeTheme @@ -505,7 +507,7 @@ private fun SavedServersSection( onRemoveServer: (SavedServer) -> Unit, ) { val theme = LocalOpenCodeTheme.current - + var pendingForget by remember { mutableStateOf?>(null) } Surface( color = theme.backgroundElement, shape = RectangleShape, @@ -515,53 +517,118 @@ private fun SavedServersSection( verticalArrangement = Arrangement.spacedBy(Spacing.xs), ) { Text( - text = "[ Saved servers ]", + text = "[ ${stringResource(R.string.server_saved_servers)} ]", style = MaterialTheme.typography.titleMedium, fontFamily = FontFamily.Monospace, color = theme.text, ) Text( - text = "Manage saved connection targets. Remove warns when open tabs reference this server.", + text = stringResource(R.string.server_saved_servers_desc), style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace, color = theme.textMuted, ) servers.forEach { server -> - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(enabled = !isConnecting, role = Role.Button) { onServerClick(server) } - .padding(vertical = Spacing.md), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(Spacing.lg), - ) { - Text("●", color = theme.success, fontFamily = FontFamily.Monospace) - Column(modifier = Modifier.weight(1f)) { - Text( - text = server.displayName, - style = MaterialTheme.typography.bodyMedium, - fontFamily = FontFamily.Monospace, - color = theme.text, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = "${server.endpoint} · ${if (server.allowInsecure) "TLS checks off" else "TLS checks on"}", - style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace, - color = theme.textMuted, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + key(server.id) { + var menuExpanded by remember { mutableStateOf(false) } + val tlsLabel = if (server.allowInsecure) { + stringResource(R.string.server_tls_checks_off) + } else { + stringResource(R.string.server_tls_checks_on) + } + val openTabCount = openTabEndpointKeys.count { it == server.endpointKey } + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = !isConnecting, role = Role.Button) { onServerClick(server) } + .padding(vertical = Spacing.md), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.lg), + ) { + Text("●", color = theme.success, fontFamily = FontFamily.Monospace) + Column(modifier = Modifier.weight(1f)) { + Text( + text = server.displayName, + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + color = theme.text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "${server.endpoint} · $tlsLabel", + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = theme.textMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (openTabCount > 0) { + Text( + text = stringResource(R.string.server_open_tabs_count, openTabCount), + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = theme.warning, + ) + } + } + Box { + IconButton( + onClick = { menuExpanded = true }, + enabled = !isConnecting, + modifier = Modifier.testTag("saved_server_actions_${server.id}"), + ) { + Icon( + Icons.Default.MoreVert, + contentDescription = stringResource( + R.string.server_actions_for, + server.displayName, + ), + tint = theme.textMuted, + ) + } + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = { menuExpanded = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.server_forget)) }, + onClick = { + menuExpanded = false + pendingForget = server to openTabCount + }, + leadingIcon = { + Icon( + Icons.Default.Delete, + contentDescription = null, + tint = theme.error, + ) + }, + ) + } + } } - Text( - text = if (server.endpointKey in openTabEndpointKeys) "warn remove" else "remove", - color = theme.warning, - fontFamily = FontFamily.Monospace, - modifier = Modifier.clickable(role = Role.Button) { onRemoveServer(server) }, - ) } } + pendingForget?.let { (server, openTabCount) -> + TuiConfirmDialog( + onDismissRequest = { pendingForget = null }, + onConfirm = { + pendingForget = null + onRemoveServer(server) + }, + title = stringResource(R.string.server_forget_title, server.displayName), + message = if (openTabCount > 0) { + stringResource(R.string.server_forget_message, openTabCount) + } else { + stringResource(R.string.server_forget_message_no_tabs) + }, + confirmText = stringResource(R.string.server_forget), + dismissText = stringResource(R.string.button_cancel), + isDestructive = true, + modifier = Modifier.testTag("saved_server_forget_dialog"), + ) + } } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f2744444..afb0bac3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -12,6 +12,18 @@ Remote Server Connect to an OpenCode server running on your network. Recent Servers + Saved servers + Connect to saved targets. Use actions for edit or forget. + TLS checks on + TLS checks off + Server actions + Server actions for %1$s + Forget server + Forget %1$s? + %1$d open tab(s) are using this server. Existing tabs will stay open until closed or reconnected, but this server will be removed from saved targets. + This server will be removed from saved targets. + %1$d open tab(s) + Saved server Discovered Servers From bd1d108e83478053d79dda360fb03cbb2e7e8f94 Mon Sep 17 00:00:00 2001 From: Jasmin Le Roux Date: Thu, 9 Jul 2026 17:41:52 +0200 Subject: [PATCH 17/22] Fix guide honesty and Home copy --- .tickets/oa-6uju.md | 8 +++++++ .tickets/oa-runv.md | 7 ++++++ .../p4oc/ui/screens/home/HomeScreen.kt | 24 ++++++++++++++----- .../blazelight/p4oc/ui/tabs/MainTabScreen.kt | 8 +++++-- 4 files changed, 39 insertions(+), 8 deletions(-) diff --git a/.tickets/oa-6uju.md b/.tickets/oa-6uju.md index cbc2bb97..cfc73e8c 100644 --- a/.tickets/oa-6uju.md +++ b/.tickets/oa-6uju.md @@ -54,3 +54,11 @@ Each scenario reports its actual evidence state: - Search the generated guide for `Missing` and verify nearby Result text does not overclaim. - Verify every referenced image exists or is intentionally labeled missing/partial. - Open the guide in Firefox and visually inspect the corrected scenarios. + +## Verification Notes + +- 2026-07-09: Corrected `local-adb-screenshots/release-v013-to-current/index.html` so `Draft persistence across tab switch`, `Chat scroll restoration`, `Rotation and ViewModel recreation`, and `Portrait state after rotation` now report partial/missing current evidence instead of `captured before/after`. +- 2026-07-09: Fixed stale `Upgrade tab survival` before-image reference from `before/023_old_pre_upgrade_visible_tab_bar.png` to existing `before/23_old_pre_upgrade_visible_tab_bar.png`. +- 2026-07-09: Corrected `Provider/model settings` to partial because the before screenshot is missing. +- 2026-07-09: Removed screenshot-count-gate wording from the guide gallery copy. +- 2026-07-09: Verified with regex search that no `Missing after/055` through `Missing after/058` scenario still says `captured before/after`. diff --git a/.tickets/oa-runv.md b/.tickets/oa-runv.md index 405940e7..b77ce9d3 100644 --- a/.tickets/oa-runv.md +++ b/.tickets/oa-runv.md @@ -73,3 +73,10 @@ Start Work should make target explicit: - Inspect Home and Start Work screenshots before/after the copy change. - Grep source for banned internal phrases: `bounded`, `without chat history`, `workspace summaries loaded`, `Current server/workspace will be explicit`. - Run `JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew :app:compileDebugKotlin && JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew :app:detekt`. + +## Verification Notes + +- 2026-07-09: Replaced Home implementation copy with task-oriented copy. Banned phrases `bounded`, `without chat history`, `workspace summaries loaded`, and `Current server/workspace will be explicit` no longer appear in `HomeScreen.kt` or `MainTabScreen.kt`. +- 2026-07-09: Start Work now shows an explicit target when a server/default workspace exists, or asks the user to choose a target before creating work. +- 2026-07-09: `JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew :app:compileDebugKotlin && JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew :app:detekt` passed. +- 2026-07-09: Device screenshot verification is still required before closing because `adb devices` currently has no connected device. Capture Home empty, Home with open work, Start Work from Home, and Start Work from active workspace before closing. diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/home/HomeScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/home/HomeScreen.kt index 16f5bf28..36636cdf 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/home/HomeScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/home/HomeScreen.kt @@ -78,7 +78,7 @@ fun HomeScreen( color = theme.text, ) Text( - text = "Open existing work, resume sessions, and browse workspaces.", + text = "Resume existing work, browse sessions, and reopen workspaces.", style = MaterialTheme.typography.bodySmall, color = theme.textMuted, ) @@ -87,7 +87,7 @@ fun HomeScreen( HomeSection( title = "Open work", - body = "${summary.openWork.size} bounded open work item${if (summary.openWork.size == 1) "" else "s"}; ${summary.workspaces.size} workspace summar${if (summary.workspaces.size == 1) "y" else "ies"} loaded without chat history.", + body = homeOpenWorkSummary(summary.openWork.size, summary.workspaces.size), ) HomeSection( @@ -133,13 +133,25 @@ fun HomeScreen( testTag = "home_open_terminal", ) - HomeSection( - title = "Attention", - body = "Attention appears as compact badges and dots on Home, tabs, servers, and workspaces — not as a feed.", - ) + if (summary.openWork.isEmpty() && summary.workspaces.isEmpty()) { + HomeSection( + title = "Tip", + body = "Use + to start a new chat, files tab, or terminal when there is nothing to resume.", + ) + } } } +private fun homeOpenWorkSummary(openWorkCount: Int, workspaceCount: Int): String = when { + openWorkCount == 0 && workspaceCount == 0 -> + "No open work yet. Browse sessions or use + to start something new." + openWorkCount == 0 -> + "$workspaceCount recent workspace${if (workspaceCount == 1) "" else "s"} ready to reopen." + else -> + "$openWorkCount open item${if (openWorkCount == 1) "" else "s"} ready to resume " + + "across $workspaceCount workspace${if (workspaceCount == 1) "" else "s"}." +} + @Composable private fun HomeSection(title: String, body: String) { val theme = LocalOpenCodeTheme.current diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/MainTabScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/MainTabScreen.kt index 464320e7..3369f8d5 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/MainTabScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/MainTabScreen.kt @@ -545,11 +545,15 @@ fun MainTabScreen( }, ) { Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm)) { + val targetLabel = workspaceLabel(targetWorkspace, tabTitleLabels) + ?: workspaceSubtitle(targetWorkspace) Text( text = if (startContext.hasExplicitTarget) { - "Target: ${workspaceLabel(targetWorkspace, tabTitleLabels) ?: workspaceSubtitle(targetWorkspace)}" + "Target: $targetLabel" + } else if (targetServer != null) { + "Target: ${targetServer.displayName} · $targetLabel" } else { - "Choose an action. Current server/workspace will be explicit before creation." + "Choose a target before creating work." }, color = theme.textMuted, style = MaterialTheme.typography.bodySmall, From 7238c5b44bd00b8d91ade282af6c79fa19a78cfb Mon Sep 17 00:00:00 2001 From: Jasmin Le Roux Date: Thu, 9 Jul 2026 17:51:01 +0200 Subject: [PATCH 18/22] Restructure Home toward workspace launcher --- .tickets/oa-runv.md | 1 + .../p4oc/ui/screens/home/HomeScreen.kt | 195 +++++++++++------- 2 files changed, 126 insertions(+), 70 deletions(-) diff --git a/.tickets/oa-runv.md b/.tickets/oa-runv.md index b77ce9d3..fc8fe937 100644 --- a/.tickets/oa-runv.md +++ b/.tickets/oa-runv.md @@ -79,4 +79,5 @@ Start Work should make target explicit: - 2026-07-09: Replaced Home implementation copy with task-oriented copy. Banned phrases `bounded`, `without chat history`, `workspace summaries loaded`, and `Current server/workspace will be explicit` no longer appear in `HomeScreen.kt` or `MainTabScreen.kt`. - 2026-07-09: Start Work now shows an explicit target when a server/default workspace exists, or asks the user to choose a target before creating work. - 2026-07-09: `JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew :app:compileDebugKotlin && JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew :app:detekt` passed. +- 2026-07-09: Home was structurally changed toward the approved mockup: compact header, Servers cards, Resume workspace rows, Browse actions, and no persistent Attention explainer block. - 2026-07-09: Device screenshot verification is still required before closing because `adb devices` currently has no connected device. Capture Home empty, Home with open work, Start Work from Home, and Start Work from active workspace before closing. diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/home/HomeScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/home/HomeScreen.kt index 36636cdf..cfe29728 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/home/HomeScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/home/HomeScreen.kt @@ -29,6 +29,9 @@ import dev.blazelight.p4oc.domain.server.WorkspaceKey import dev.blazelight.p4oc.ui.theme.Spacing import dev.blazelight.p4oc.ui.theme.TuiShapes +private const val HOME_SERVER_CARD_LIMIT = 2 +private const val HOME_WORKSPACE_LIMIT = 4 + @Composable fun HomeScreen( summary: HomeSummaryState, @@ -62,94 +65,146 @@ fun HomeScreen( .testTag("home_screen"), verticalArrangement = Arrangement.spacedBy(Spacing.md), ) { - Row( - horizontalArrangement = Arrangement.spacedBy(Spacing.sm), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Default.Home, - contentDescription = null, - tint = theme.text, - ) - Column(verticalArrangement = Arrangement.spacedBy(Spacing.xxs)) { - Text( - text = "Home", - style = MaterialTheme.typography.titleMedium, - color = theme.text, - ) - Text( - text = "Resume existing work, browse sessions, and reopen workspaces.", - style = MaterialTheme.typography.bodySmall, - color = theme.textMuted, - ) - } - } - - HomeSection( - title = "Open work", - body = homeOpenWorkSummary(summary.openWork.size, summary.workspaces.size), + homeHeader( + serverCount = summary.servers.size, + openWorkCount = summary.openWork.size, ) - HomeSection( - title = "Servers", - body = summary.servers.joinToString { "${it.displayName}: ${it.openTabCount} tabs" } - .ifBlank { "No saved servers yet." }, - ) + if (summary.servers.isNotEmpty()) { + sectionLabel("Servers") + Row( + horizontalArrangement = Arrangement.spacedBy(Spacing.sm), + modifier = Modifier.fillMaxWidth(), + ) { + summary.servers.take(HOME_SERVER_CARD_LIMIT).forEach { server -> + serverCard( + server = server, + modifier = Modifier.weight(1f), + ) + } + } + } - summary.workspaces.take(6).forEach { workspace -> - HomeActionRow( - label = workspace.workspaceKey.displayLabel(), - description = "${workspace.serverRef.displayName} · ${workspace.openTabCount} open item${if (workspace.openTabCount == 1) "" else "s"}", - icon = { Icon(Icons.Default.Folder, contentDescription = null, tint = theme.textMuted) }, - onClick = { - selectedWorkspace = workspace - onWorkspaceSelected(workspace) - }, - testTag = "home_workspace_${workspace.serverRef.endpointKey}_${workspace.workspaceKey.displayLabel()}", - ) + sectionLabel("Resume") + if (summary.workspaces.isEmpty()) { + emptyHomeCard() + } else { + summary.workspaces.take(HOME_WORKSPACE_LIMIT).forEach { workspace -> + workspaceRow( + workspace = workspace, + onClick = { + selectedWorkspace = workspace + onWorkspaceSelected(workspace) + }, + ) + } } + sectionLabel("Browse") HomeActionRow( - label = "Browse sessions", - description = "Search, resume, rename, share, summarize, delete, or view changes.", + label = "Sessions", + description = "Find previous chats and workspace history.", icon = { Icon(Icons.Default.ViewList, contentDescription = null, tint = theme.textMuted) }, onClick = onBrowseSessions, testTag = "home_browse_sessions", ) - HomeActionRow( - label = "Open files", - description = "Open the current workspace files tab.", - icon = { Icon(Icons.Default.Folder, contentDescription = null, tint = theme.textMuted) }, - onClick = onOpenFiles, - testTag = "home_open_files", - ) + Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { + HomeActionRow( + label = "Files", + description = "Open file browser.", + icon = { Icon(Icons.Default.Folder, contentDescription = null, tint = theme.textMuted) }, + onClick = onOpenFiles, + testTag = "home_open_files", + modifier = Modifier.weight(1f), + ) + HomeActionRow( + label = "Terminal", + description = "Open shell.", + icon = { Icon(Icons.Default.Terminal, contentDescription = null, tint = theme.textMuted) }, + onClick = onOpenTerminal, + testTag = "home_open_terminal", + modifier = Modifier.weight(1f), + ) + } + } +} - HomeActionRow( - label = "Open terminal", - description = "Create a terminal in the current workspace context.", - icon = { Icon(Icons.Default.Terminal, contentDescription = null, tint = theme.textMuted) }, - onClick = onOpenTerminal, - testTag = "home_open_terminal", - ) +@Composable +private fun homeHeader(serverCount: Int, openWorkCount: Int) { + val theme = LocalOpenCodeTheme.current + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RectangleShape, + color = theme.backgroundElement, + ) { + Row( + modifier = Modifier.padding(Spacing.md), + horizontalArrangement = Arrangement.spacedBy(Spacing.md), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.Default.Home, contentDescription = null, tint = theme.accent) + Column(verticalArrangement = Arrangement.spacedBy(Spacing.xxs)) { + Text("Home", style = MaterialTheme.typography.titleMedium, color = theme.text) + Text( + "$openWorkCount open · $serverCount server${if (serverCount == 1) "" else "s"}", + style = MaterialTheme.typography.bodySmall, + color = theme.textMuted, + ) + } + } + } +} + +@Composable +private fun sectionLabel(text: String) { + val theme = LocalOpenCodeTheme.current + Text( + text = text.uppercase(), + style = MaterialTheme.typography.labelMedium, + color = theme.textMuted, + ) +} - if (summary.openWork.isEmpty() && summary.workspaces.isEmpty()) { - HomeSection( - title = "Tip", - body = "Use + to start a new chat, files tab, or terminal when there is nothing to resume.", +@Composable +private fun serverCard(server: ServerSummary, modifier: Modifier = Modifier) { + val theme = LocalOpenCodeTheme.current + Surface( + modifier = modifier, + shape = RectangleShape, + color = theme.backgroundElement, + ) { + Column( + modifier = Modifier.padding(Spacing.sm), + verticalArrangement = Arrangement.spacedBy(Spacing.xxs), + ) { + Text(server.displayName, style = MaterialTheme.typography.labelMedium, color = theme.text) + Text( + "${server.openTabCount} open", + style = MaterialTheme.typography.bodySmall, + color = theme.textMuted, ) } } } -private fun homeOpenWorkSummary(openWorkCount: Int, workspaceCount: Int): String = when { - openWorkCount == 0 && workspaceCount == 0 -> - "No open work yet. Browse sessions or use + to start something new." - openWorkCount == 0 -> - "$workspaceCount recent workspace${if (workspaceCount == 1) "" else "s"} ready to reopen." - else -> - "$openWorkCount open item${if (openWorkCount == 1) "" else "s"} ready to resume " + - "across $workspaceCount workspace${if (workspaceCount == 1) "" else "s"}." +@Composable +private fun workspaceRow(workspace: WorkspaceSummary, onClick: () -> Unit) { + HomeActionRow( + label = workspace.workspaceKey.displayLabel(), + description = "${workspace.serverRef.displayName} · ${workspace.openTabCount} open", + icon = { Icon(Icons.Default.Folder, contentDescription = null, tint = LocalOpenCodeTheme.current.textMuted) }, + onClick = onClick, + testTag = "home_workspace_${workspace.serverRef.endpointKey}_${workspace.workspaceKey.displayLabel()}", + ) +} + +@Composable +private fun emptyHomeCard() { + HomeSection( + title = "No open work", + body = "Browse sessions to resume work, or use + to start something new.", + ) } @Composable From 8cb7a1c9ce057eae2b851adef999ee71f6c22cd0 Mon Sep 17 00:00:00 2001 From: Jasmin Le Roux Date: Thu, 9 Jul 2026 22:12:06 +0200 Subject: [PATCH 19/22] Establish scoped server identity contracts --- .tickets/oa-78mo.md | 36 +++ .tickets/oa-agea.md | 37 +++ .tickets/oa-cxp9.md | 37 +++ .tickets/oa-hrtb.md | 35 +++ .tickets/oa-iq18.md | 36 +++ .tickets/oa-runv.md | 2 +- .tickets/oa-sa63.md | 41 +++ .tickets/oa-zmqg.md | 41 +++ .../p4oc/core/datastore/SettingsDataStore.kt | 14 +- .../p4oc/domain/server/ServerIdentity.kt | 64 +++++ .../p4oc/domain/server/ServerRef.kt | 8 +- .../p4oc/ui/screens/home/HomeScreen.kt | 212 +++++++++----- .../p4oc/ui/screens/server/ServerScreen.kt | 7 +- .../p4oc/ui/screens/server/ServerViewModel.kt | 13 +- .../blazelight/p4oc/ui/tabs/MainTabScreen.kt | 263 ++++++++++-------- .../p4oc/ui/tabs/StartWorkContext.kt | 48 +++- .../dev/blazelight/p4oc/ui/tabs/TabBar.kt | 3 + .../dev/blazelight/p4oc/ui/tabs/TabNavHost.kt | 81 ++++-- app/src/main/res/values/strings.xml | 3 +- .../core/datastore/SavedServerRegistryTest.kt | 39 ++- .../p4oc/domain/server/ServerIdentityTest.kt | 80 ++++++ .../p4oc/ui/tabs/StartWorkContextTest.kt | 101 +++++-- .../p4oc/ui/tabs/TabBarTitleTest.kt | 1 + 23 files changed, 945 insertions(+), 257 deletions(-) create mode 100644 .tickets/oa-78mo.md create mode 100644 .tickets/oa-agea.md create mode 100644 .tickets/oa-cxp9.md create mode 100644 .tickets/oa-hrtb.md create mode 100644 .tickets/oa-iq18.md create mode 100644 .tickets/oa-sa63.md create mode 100644 .tickets/oa-zmqg.md create mode 100644 app/src/main/java/dev/blazelight/p4oc/domain/server/ServerIdentity.kt create mode 100644 app/src/test/java/dev/blazelight/p4oc/domain/server/ServerIdentityTest.kt diff --git a/.tickets/oa-78mo.md b/.tickets/oa-78mo.md new file mode 100644 index 00000000..fe6a4a20 --- /dev/null +++ b/.tickets/oa-78mo.md @@ -0,0 +1,36 @@ +--- +id: oa-78mo +status: open +deps: [oa-zmqg, oa-sa63] +links: [] +created: 2026-07-09T19:49:06Z +type: task +priority: 1 +assignee: Jasmin Le Roux +--- +# Refocus Sessions on existing work + +## Problem +Sessions prominently presents New Chat and Open Files creation cards, confusing the product split between browsing existing work and creating via `+`. + +## Evidence / Repro +`current-phone-ux-audit.png` shows creation rows before the session list, while Home and Sessions compete as global landing surfaces. + +## UX Constraint +Home and Sessions browse/resume existing work; persistent `+` creates new work. Sessions remains the search/history/actions surface and preserves exact server/workspace identity. + +## Design + +Sessions is contextual existing-work history, not a parallel creation dashboard. Reuse server badges and centralized status components. + +## Acceptance Criteria + +- Prominent New Chat/Open Files creation cards are removed from Sessions. +- Sessions leads with scoped search/filter/history and resumable existing sessions. +- Rows show durable server/workspace identity, status/recency, and explicit open/resume behavior. +- Global and workspace-filtered Sessions states are distinct without creating a hidden global server mode. +- Session actions preserve immutable ownership. +- Empty state directs creation through persistent + without duplicating a large creation panel. +- Current-device screenshot covers populated and empty states. +- Compile, detekt, and affected tests pass. + diff --git a/.tickets/oa-agea.md b/.tickets/oa-agea.md new file mode 100644 index 00000000..41e4bcea --- /dev/null +++ b/.tickets/oa-agea.md @@ -0,0 +1,37 @@ +--- +id: oa-agea +status: open +deps: [oa-zmqg, oa-sa63] +links: [] +created: 2026-07-09T19:48:26Z +type: feature +priority: 1 +assignee: Jasmin Le Roux +--- +# Rebuild Home as existing-work dashboard + +## Problem +Current Home is a flat launcher whose workspaces are derived only from open tabs. It omits resumable work, session recency, status/count context, and safe contextual actions from the approved prototype. + +## Evidence / Repro +`HomeSummary` models open-tab-derived workspaces and routes but not session counts, recency, or previews. Current Home exposes flat Servers/Resume/Browse sections. Reference: `local-adb-screenshots/home-workspace-detail-plus.html`. + +## UX Constraint +Home opens/resumes/browses existing work. `+` creates new work. Servers are filters/identity, not a hidden global mode. Notifications stay compact. + +## Design + +Match the information architecture, not necessarily the duplicate bottom navigation, in `home-workspace-detail-plus.html`: compact server strip, one-column workspace cards, recent sessions. Depend on durable identity and explicit scope contracts. + +## Acceptance Criteria + +- Home has explicit `[ Home ]` identity and a Servers management action. +- A compact All/server filter strip shows badge, friendly name, centralized status, session count, and open-tab count without retargeting tabs. +- Recent workspace cards show server badge/name, workspace/path, session/open-tab counts, and scoped Open/Files/Terminal actions. +- Two to four resumable session previews show meaningful title, server/workspace, recency/status, and Resume; View all opens scoped Sessions. +- Workspaces with sessions but no open tab can appear. +- Empty, loading, partial-failure, and populated states remain task-oriented and scroll correctly on phone. +- Home does not duplicate prominent creation actions or become a notification feed. +- Current-device screenshots cover empty and populated/multi-server states. +- Compile, detekt, and affected tests pass. + diff --git a/.tickets/oa-cxp9.md b/.tickets/oa-cxp9.md new file mode 100644 index 00000000..6aa2f9d2 --- /dev/null +++ b/.tickets/oa-cxp9.md @@ -0,0 +1,37 @@ +--- +id: oa-cxp9 +status: open +deps: [oa-zmqg, oa-sa63] +links: [] +created: 2026-07-09T19:48:46Z +type: feature +priority: 1 +assignee: Jasmin Le Roux +--- +# Replace Start Work with scoped place flow + +## Problem +Start Work exposes an architectural `Target` concept, silently defaults ownership, and uses a Files-specific chooser that loses server identity. + +## Evidence / Repro +Current dialog shows three inconsistent target branches and five equal-weight actions. Home workspace detail state is private and never reaches `StartWorkContext`; `defaultAction` is unused; ambiguous paths fall back to current server/global workspace. + +UX Constraint +The common contextual path should be `+` then one action. Destination remains visibly explicit and cannot be guessed across servers. + +## Design + +Use one coordinator and one bottom-sheet flow. Preserve selected scope and pending action through auth/reconnect. Reuse exact scope from Home detail and work tabs. + +## Acceptance Criteria + +- Start Work uses a context-first bottom sheet showing `In` plus badge, server name, workspace/path, centralized status, and Change. +- Primary actions are New chat, Files, and Terminal; scoped Sessions is secondary. +- Home detail, Chat, Files, and Terminal inherit immutable owning scope. +- Ambiguous Home/global invocation opens a grouped server/workspace picker before actions. +- Picker groups by friendly server identity and includes explicit `No project context`; it never lists an ownerless path. +- No configured, offline, auth-required, removed-server, and missing-workspace states retain intent and offer appropriate recovery without retargeting. +- No fallback to current server or implicit Global remains. +- Current-device screenshots cover contextual and ambiguous invocations. +- Compile, detekt, and affected tests pass. + diff --git a/.tickets/oa-hrtb.md b/.tickets/oa-hrtb.md new file mode 100644 index 00000000..140169f1 --- /dev/null +++ b/.tickets/oa-hrtb.md @@ -0,0 +1,35 @@ +--- +id: oa-hrtb +status: open +deps: [oa-zmqg] +links: [] +created: 2026-07-09T19:48:57Z +type: task +priority: 1 +assignee: Jasmin Le Roux +--- +# Stabilize work tab identity and titles + +## Problem +Tab titles are generic, route-derived, and heavily truncated (`Tab`, `Global`, `Sessions · …`, `Terminal d…`), so users cannot safely distinguish work spanning multiple servers. + +## Evidence / Repro +The pinned Home route previously fell through to `Tab`; nested routes update generic title mappings; the current phone screenshot shows ambiguous and truncated labels. + +## UX Constraint +Pinned Home is the fixed global anchor. Closeable tabs represent stable work objects with visible server/workspace identity; nested navigation must not rename them. + +## Design + +Use fixed icon-only Home on narrow widths or `Home` where space permits, scrolling closeable tabs, and fixed trailing +. Do not add duplicate bottom navigation. + +## Acceptance Criteria + +- Pinned non-closeable Home has explicit localized Home title/icon and never uses generic fallback. +- Work tabs have stable object identity independent of nested route changes. +- Each closeable tab exposes deterministic server badge plus meaningful work title and workspace context within phone constraints. +- `Global` is replaced in user-facing identity by explicit `No project context` where intentionally selected. +- Accessibility names retain full server/workspace/object identity when visible text truncates. +- Mixed-server and narrow-width title behavior is tested and verified on device. +- Compile and detekt pass. + diff --git a/.tickets/oa-iq18.md b/.tickets/oa-iq18.md new file mode 100644 index 00000000..9bc7deb6 --- /dev/null +++ b/.tickets/oa-iq18.md @@ -0,0 +1,36 @@ +--- +id: oa-iq18 +status: open +deps: [oa-zmqg, oa-sa63] +links: [] +created: 2026-07-09T19:48:36Z +type: feature +priority: 1 +assignee: Jasmin Le Roux +--- +# Build scoped Home workspace detail + +## Problem +Home workspace detail displays raw route strings and promises scoped browsing while its callbacks can perform global/current-server actions. + +## Evidence / Repro +Current detail shows raw routes such as `chat/...`; Browse filtered sessions does not implement a filtered destination; Files and Terminal callbacks omit ownership. + +## UX Constraint +Workspace click drills into Home without creating a tab. Detail is an existing-work surface first; creation actions are subordinate and always exactly scoped. + +## Design + +Use the approved workspace-detail structure in `home-workspace-detail-plus.html`. Reuse Home identity/status components and immutable scoped callbacks. + +## Acceptance Criteria + +- Detail header shows back, workspace name/path, durable server badge/name, and centralized status. +- Open work is represented as typed Chat/Files/Terminal cards with meaningful titles/status and Focus actions, never raw routes. +- Sessions are filtered to exact server/workspace, searchable when useful, and resumable. +- New chat/Files/Terminal actions are visually subordinate and invoke the shared coordinator with exact ownership. +- Back returns to the prior Home filter/scroll context without tab creation. +- Mixed-server same-directory behavior is tested. +- Current-device screenshot verifies populated detail. +- Compile, detekt, and affected tests pass. + diff --git a/.tickets/oa-runv.md b/.tickets/oa-runv.md index fc8fe937..34651c32 100644 --- a/.tickets/oa-runv.md +++ b/.tickets/oa-runv.md @@ -1,7 +1,7 @@ --- id: oa-runv status: open -deps: [] +deps: [oa-cxp9, oa-agea] links: [] created: 2026-07-09T15:15:06Z type: task diff --git a/.tickets/oa-sa63.md b/.tickets/oa-sa63.md new file mode 100644 index 00000000..d3445b45 --- /dev/null +++ b/.tickets/oa-sa63.md @@ -0,0 +1,41 @@ +--- +id: oa-sa63 +status: closed +deps: [] +links: [] +created: 2026-07-09T19:48:15Z +type: task +priority: 0 +assignee: Jasmin Le Roux +--- +# Require explicit server and workspace ownership + +## Problem +Creation, browse, and workspace-detail callbacks can omit ownership and fall back to `currentServerRef` or `WorkspaceKey.Global`, allowing work to target the wrong server when tabs span servers. + +## Evidence / Repro +The Start Work coordinator silently falls back to current server/global workspace; Home workspace-detail Files/Terminal callbacks do not carry server/workspace arguments; the existing chooser is Files-specific and inferred from open tabs. + +## UX Constraint +Every workspace-required action must carry immutable `ServerRef + WorkspaceKey`. Missing scope is an explicit selection state, never a guess, nullable default, fallback chain, or global escape hatch. + +## Design + +Introduce or reuse one immutable scoped identity value rather than parallel nullable parameters. Migrate every caller cleanly; leave no compatibility overloads or deprecated fallback paths. + +## Acceptance Criteria + +- Workspace-required callbacks and exported APIs require non-null `ServerRef + WorkspaceKey` (or a single non-null scoped value object). +- Home detail, Sessions, Chat, Files, Terminal, and Start Work invoke actions with their owning scope. +- No workspace-required path falls back to `currentServerRef`, active tab state, settings lastProject, or implicit `WorkspaceKey.Global`. +- `No project context` remains available only as an explicit user-visible choice. +- Missing/removed/ambiguous ownership routes to explicit target selection without retargeting. +- Tests cover mixed-server same-directory names, missing owner, explicit no-project context, and removed server. +- Compile, detekt, and affected unit tests pass. + + +## Notes + +**2026-07-09T20:11:48Z** + +Implemented typed StartWorkTarget/StartWorkSelection and NavigationWorkspaceSelection contracts. Home detail and Start Work actions carry exact ServerRef + WorkspaceKey; ambiguous or removed owners require selection; No project context is explicit; blank directories reject instead of falling back. Added mixed-server/missing-owner/removed-server coverage. Verified :app:compileDebugKotlin, :app:detekt, and :app:testDebugUnitTest pass. Pre-existing nullable/global semantics outside this ticket remain in persistence/network boundary models and should be assessed separately rather than treated as this flow's fallback. diff --git a/.tickets/oa-zmqg.md b/.tickets/oa-zmqg.md new file mode 100644 index 00000000..e464bab0 --- /dev/null +++ b/.tickets/oa-zmqg.md @@ -0,0 +1,41 @@ +--- +id: oa-zmqg +status: closed +deps: [] +links: [] +created: 2026-07-09T19:47:49Z +type: task +priority: 0 +assignee: Jasmin Le Roux +--- +# Establish durable server names and badges + +Problem: +Saved remote targets use the meaningless display name `Remote Server`, so tabs spanning servers cannot be safely distinguished. + +Evidence: +`ServerViewModel.kt` hard-codes `Remote Server` for successful manual connections and persistence. Current device screenshots show the same label for saved and recent entries. + +UX Constraint: +Server identity must be durable, user-editable, and distinct from connection status. Endpoint remains secondary detail. Never encode identity solely with status color. + +## Design + +Use discovery/mDNS service name when meaningful, then hostname, then host:port. Badge initials and accent must be deterministic from canonical server identity. Follow sharp dense TUI styling and resource-backed copy. + +## Acceptance Criteria + +- New saved servers require or derive a meaningful editable name from discovery name, hostname, or host:port; never `Remote Server`. +- Existing generic records receive a deterministic recognizable migration/fallback label. +- Every saved server has a deterministic compact badge identity reusable across Home, tabs, Sessions, server management, dialogs, and target pickers. +- Badge identity and status indicator are separate semantics. +- Rename behavior preserves endpoint identity and updates all consumers. +- Unit tests cover derivation, migration/fallback, stability, and collisions. +- Compile, detekt, and affected unit tests pass. + + +## Notes + +**2026-07-09T20:11:41Z** + +Implemented centralized ServerIdentity naming and deterministic badge semantics. Removed production `Remote Server`; new/manual/discovered and legacy saved records derive recognizable identity without changing canonical endpoint behavior. Added focused ServerIdentity and SavedServerRegistry coverage. Verified :app:compileDebugKotlin, :app:detekt, and :app:testDebugUnitTest pass. diff --git a/app/src/main/java/dev/blazelight/p4oc/core/datastore/SettingsDataStore.kt b/app/src/main/java/dev/blazelight/p4oc/core/datastore/SettingsDataStore.kt index 847095f3..dca8e013 100644 --- a/app/src/main/java/dev/blazelight/p4oc/core/datastore/SettingsDataStore.kt +++ b/app/src/main/java/dev/blazelight/p4oc/core/datastore/SettingsDataStore.kt @@ -6,9 +6,10 @@ import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.* import androidx.datastore.preferences.preferencesDataStore import dev.blazelight.p4oc.core.log.AppLog -import dev.blazelight.p4oc.core.security.CredentialStore import dev.blazelight.p4oc.core.network.ServerUrl +import dev.blazelight.p4oc.core.security.CredentialStore import dev.blazelight.p4oc.data.remote.dto.ModelInput +import dev.blazelight.p4oc.domain.server.ServerIdentity import dev.blazelight.p4oc.domain.server.WorkspaceKey import dev.blazelight.p4oc.domain.session.SessionId import kotlinx.coroutines.CoroutineScope @@ -762,7 +763,10 @@ data class SavedServer( val pinned: Boolean = false, val defaultWorkspace: String? = null, val lastConnectedAt: Long? = null, -) +) { + val badgeLabel: String + get() = ServerIdentity.derive(endpointKey, displayName).badgeLabel +} internal object SavedServerRegistry { fun fromConnection( @@ -778,11 +782,12 @@ internal object SavedServerRegistry { ?: throw IllegalArgumentException("Invalid server endpoint: $url") val endpointKey = ServerUrl.endpointKey(endpoint) ?: throw IllegalArgumentException("Invalid server endpoint: $url") + val identity = ServerIdentity.derive(endpointKey, name) return SavedServer( id = endpointKey, endpoint = endpoint, endpointKey = endpointKey, - displayName = name.takeIf { it.isNotBlank() } ?: endpoint, + displayName = identity.displayName, username = username, allowInsecure = allowInsecure, pinned = pinned, @@ -796,11 +801,12 @@ internal object SavedServerRegistry { ?: throw IllegalArgumentException("Invalid server endpoint: ${server.endpoint}") val endpointKey = ServerUrl.endpointKey(endpoint) ?: throw IllegalArgumentException("Invalid server endpoint: ${server.endpoint}") + val identity = ServerIdentity.derive(endpointKey, server.displayName) return server.copy( id = server.id.ifBlank { endpointKey }, endpoint = endpoint, endpointKey = endpointKey, - displayName = server.displayName.takeIf { it.isNotBlank() } ?: endpoint, + displayName = identity.displayName, defaultWorkspace = server.defaultWorkspace?.takeIf { it.isNotBlank() }, ) } diff --git a/app/src/main/java/dev/blazelight/p4oc/domain/server/ServerIdentity.kt b/app/src/main/java/dev/blazelight/p4oc/domain/server/ServerIdentity.kt new file mode 100644 index 00000000..888d9039 --- /dev/null +++ b/app/src/main/java/dev/blazelight/p4oc/domain/server/ServerIdentity.kt @@ -0,0 +1,64 @@ +package dev.blazelight.p4oc.domain.server + +import dev.blazelight.p4oc.core.network.ServerUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import java.util.Locale + +data class ServerIdentity( + val displayName: String, + val badgeLabel: String, +) { + companion object { + private val genericNames = setOf( + "remote", + "remote server", + "server", + "opencode", + "opencode server", + ) + + fun derive(endpoint: String, candidateName: String? = null): ServerIdentity { + val endpointKey = ServerUrl.endpointKey(endpoint) + ?: throw IllegalArgumentException("Invalid server endpoint: $endpoint") + val parsed = endpointKey.toHttpUrlOrNull() + ?: throw IllegalArgumentException("Invalid server endpoint: $endpoint") + val candidate = candidateName?.trim()?.takeUnless(::isGenericName) + val displayName = candidate ?: endpointDisplayName(parsed.host, parsed.port) + return ServerIdentity( + displayName = displayName, + badgeLabel = badgeLabel(displayName, endpointKey, isEndpointDerived = candidate == null), + ) + } + + fun isGenericName(name: String?): Boolean { + val normalized = name + ?.trim() + ?.lowercase(Locale.ROOT) + ?.replace(Regex("[\\s_-]+"), " ") + .orEmpty() + return normalized.isBlank() || normalized in genericNames + } + + private fun endpointDisplayName(host: String, port: Int): String { + val isIpAddress = host.all { it.isDigit() || it == '.' } || ':' in host + if (!isIpAddress || host.equals("localhost", ignoreCase = true)) return host + val formattedHost = if (':' in host) "[$host]" else host + return "$formattedHost:$port" + } + + private fun badgeLabel(displayName: String, endpointKey: String, isEndpointDerived: Boolean): String { + val words = displayName.split(Regex("[^\\p{L}\\p{N}]+")) + .filter(String::isNotEmpty) + val stem = when { + isEndpointDerived && words.size >= 2 -> "${words[0].first()}${words[1].first()}" + isEndpointDerived && words.isNotEmpty() -> words.first().take(2) + words.size >= 2 -> "${words.first().first()}${words.last().first()}" + words.isNotEmpty() -> words.first().take(2) + else -> "SV" + }.uppercase(Locale.ROOT).padEnd(2, 'X') + val hash = endpointKey.hashCode().toUInt().toString(radix = 36).uppercase(Locale.ROOT) + .padStart(2, '0').takeLast(2) + return stem + hash + } + } +} diff --git a/app/src/main/java/dev/blazelight/p4oc/domain/server/ServerRef.kt b/app/src/main/java/dev/blazelight/p4oc/domain/server/ServerRef.kt index 0a26d380..00b18cfb 100644 --- a/app/src/main/java/dev/blazelight/p4oc/domain/server/ServerRef.kt +++ b/app/src/main/java/dev/blazelight/p4oc/domain/server/ServerRef.kt @@ -13,6 +13,8 @@ class ServerRef private constructor( val endpointKey: String, val displayName: String, ) { + val badgeLabel: String + get() = ServerIdentity.derive(endpointKey, displayName).badgeLabel override fun equals(other: Any?): Boolean = this === other || (other is ServerRef && endpointKey == other.endpointKey) @@ -22,19 +24,21 @@ class ServerRef private constructor( companion object { fun fromEndpoint(input: String, displayName: String? = null): ServerRef { + val identity = ServerIdentity.derive(input, displayName) val key = ServerUrl.endpointKey(input) ?: throw IllegalArgumentException("Invalid server endpoint: $input") return ServerRef( endpointKey = key, - displayName = displayName?.takeIf { it.isNotBlank() } ?: key, + displayName = identity.displayName, ) } fun fromEndpointKey(endpointKey: String, displayName: String? = null): ServerRef { require(endpointKey.isNotBlank()) { "Endpoint key must not be blank" } + val identity = ServerIdentity.derive(endpointKey, displayName) return ServerRef( endpointKey = endpointKey, - displayName = displayName?.takeIf { it.isNotBlank() } ?: endpointKey, + displayName = identity.displayName, ) } } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/home/HomeScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/home/HomeScreen.kt index cfe29728..4dc9de08 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/home/HomeScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/home/HomeScreen.kt @@ -24,40 +24,81 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.platform.testTag -import dev.blazelight.p4oc.ui.theme.LocalOpenCodeTheme import dev.blazelight.p4oc.domain.server.WorkspaceKey +import dev.blazelight.p4oc.ui.tabs.StartWorkSelection +import dev.blazelight.p4oc.ui.tabs.StartWorkTarget +import dev.blazelight.p4oc.ui.theme.LocalOpenCodeTheme import dev.blazelight.p4oc.ui.theme.Spacing import dev.blazelight.p4oc.ui.theme.TuiShapes private const val HOME_SERVER_CARD_LIMIT = 2 private const val HOME_WORKSPACE_LIMIT = 4 +data class HomeActions( + val onBrowseSessions: (StartWorkTarget) -> Unit, + val onOpenFiles: (StartWorkTarget) -> Unit, + val onOpenTerminal: (StartWorkTarget) -> Unit, + val onChooseTarget: () -> Unit, + val onWorkspaceSelected: (WorkspaceSummary) -> Unit = {}, + val onWorkspaceDetailChanged: (StartWorkSelection) -> Unit = {}, +) + +private data class WorkspaceDetailActions( + val onBack: () -> Unit, + val onBrowseSessions: () -> Unit, + val onOpenFiles: () -> Unit, + val onOpenTerminal: () -> Unit, +) + @Composable -fun HomeScreen( +fun homeScreen( summary: HomeSummaryState, - onBrowseSessions: () -> Unit, - onOpenFiles: () -> Unit, - onOpenTerminal: () -> Unit, + actions: HomeActions, modifier: Modifier = Modifier, - onWorkspaceSelected: (WorkspaceSummary) -> Unit = {}, ) { - val theme = LocalOpenCodeTheme.current - var selectedWorkspace by remember { mutableStateOf(null) } val selected = selectedWorkspace if (selected != null) { - WorkspaceDetail( + workspaceDetail( workspace = selected, openWork = summary.openWork.filter { it.serverRef == selected.serverRef && it.workspaceKey == selected.workspaceKey }, - onBack = { selectedWorkspace = null }, - onOpenFiles = onOpenFiles, - onOpenTerminal = onOpenTerminal, + actions = WorkspaceDetailActions( + onBack = { + selectedWorkspace = null + actions.onWorkspaceDetailChanged(StartWorkSelection.NeedsSelection) + }, + onBrowseSessions = { actions.onBrowseSessions(selected.toStartWorkTarget()) }, + onOpenFiles = { actions.onOpenFiles(selected.toStartWorkTarget()) }, + onOpenTerminal = { actions.onOpenTerminal(selected.toStartWorkTarget()) }, + ), modifier = modifier, ) return } + homeOverview( + summary = summary, + onWorkspaceClick = { workspace -> + selectedWorkspace = workspace + actions.onWorkspaceSelected(workspace) + actions.onWorkspaceDetailChanged( + StartWorkSelection.Selected(workspace.toStartWorkTarget()), + ) + }, + onChooseTarget = actions.onChooseTarget, + modifier = modifier, + ) +} + +@Composable +private fun homeOverview( + summary: HomeSummaryState, + onWorkspaceClick: (WorkspaceSummary) -> Unit, + onChooseTarget: () -> Unit, + modifier: Modifier, +) { + val theme = LocalOpenCodeTheme.current Column( modifier = modifier .fillMaxSize() @@ -65,71 +106,73 @@ fun HomeScreen( .testTag("home_screen"), verticalArrangement = Arrangement.spacedBy(Spacing.md), ) { - homeHeader( - serverCount = summary.servers.size, - openWorkCount = summary.openWork.size, - ) - - if (summary.servers.isNotEmpty()) { - sectionLabel("Servers") - Row( - horizontalArrangement = Arrangement.spacedBy(Spacing.sm), - modifier = Modifier.fillMaxWidth(), - ) { - summary.servers.take(HOME_SERVER_CARD_LIMIT).forEach { server -> - serverCard( - server = server, - modifier = Modifier.weight(1f), - ) - } - } - } - - sectionLabel("Resume") - if (summary.workspaces.isEmpty()) { - emptyHomeCard() - } else { - summary.workspaces.take(HOME_WORKSPACE_LIMIT).forEach { workspace -> - workspaceRow( - workspace = workspace, - onClick = { - selectedWorkspace = workspace - onWorkspaceSelected(workspace) - }, - ) - } - } - + homeHeader(summary.servers.size, summary.openWork.size) + serverOverview(summary.servers) + workspaceOverview(summary.workspaces, onWorkspaceClick) sectionLabel("Browse") HomeActionRow( label = "Sessions", description = "Find previous chats and workspace history.", icon = { Icon(Icons.Default.ViewList, contentDescription = null, tint = theme.textMuted) }, - onClick = onBrowseSessions, + onClick = onChooseTarget, testTag = "home_browse_sessions", ) + browseActions(onChooseTarget) + } +} - Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { - HomeActionRow( - label = "Files", - description = "Open file browser.", - icon = { Icon(Icons.Default.Folder, contentDescription = null, tint = theme.textMuted) }, - onClick = onOpenFiles, - testTag = "home_open_files", - modifier = Modifier.weight(1f), - ) - HomeActionRow( - label = "Terminal", - description = "Open shell.", - icon = { Icon(Icons.Default.Terminal, contentDescription = null, tint = theme.textMuted) }, - onClick = onOpenTerminal, - testTag = "home_open_terminal", - modifier = Modifier.weight(1f), - ) +@Composable +private fun serverOverview(servers: List) { + if (servers.isEmpty()) return + sectionLabel("Servers") + Row( + horizontalArrangement = Arrangement.spacedBy(Spacing.sm), + modifier = Modifier.fillMaxWidth(), + ) { + servers.take(HOME_SERVER_CARD_LIMIT).forEach { server -> + serverCard(server, Modifier.weight(1f)) + } + } +} + +@Composable +private fun workspaceOverview( + workspaces: List, + onWorkspaceClick: (WorkspaceSummary) -> Unit, +) { + sectionLabel("Resume") + if (workspaces.isEmpty()) { + emptyHomeCard() + } else { + workspaces.take(HOME_WORKSPACE_LIMIT).forEach { workspace -> + workspaceRow(workspace) { onWorkspaceClick(workspace) } } } } +@Composable +private fun browseActions(onChooseTarget: () -> Unit) { + val theme = LocalOpenCodeTheme.current + Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { + HomeActionRow( + label = "Files", + description = "Open file browser.", + icon = { Icon(Icons.Default.Folder, contentDescription = null, tint = theme.textMuted) }, + onClick = onChooseTarget, + testTag = "home_open_files", + modifier = Modifier.weight(1f), + ) + HomeActionRow( + label = "Terminal", + description = "Open shell.", + icon = { Icon(Icons.Default.Terminal, contentDescription = null, tint = theme.textMuted) }, + onClick = onChooseTarget, + testTag = "home_open_terminal", + modifier = Modifier.weight(1f), + ) + } +} + @Composable private fun homeHeader(serverCount: Int, openWorkCount: Int) { val theme = LocalOpenCodeTheme.current @@ -178,7 +221,11 @@ private fun serverCard(server: ServerSummary, modifier: Modifier = Modifier) { modifier = Modifier.padding(Spacing.sm), verticalArrangement = Arrangement.spacedBy(Spacing.xxs), ) { - Text(server.displayName, style = MaterialTheme.typography.labelMedium, color = theme.text) + Text( + "${server.serverRef.badgeLabel} ${server.displayName}", + style = MaterialTheme.typography.labelMedium, + color = theme.text, + ) Text( "${server.openTabCount} open", style = MaterialTheme.typography.bodySmall, @@ -192,8 +239,15 @@ private fun serverCard(server: ServerSummary, modifier: Modifier = Modifier) { private fun workspaceRow(workspace: WorkspaceSummary, onClick: () -> Unit) { HomeActionRow( label = workspace.workspaceKey.displayLabel(), - description = "${workspace.serverRef.displayName} · ${workspace.openTabCount} open", - icon = { Icon(Icons.Default.Folder, contentDescription = null, tint = LocalOpenCodeTheme.current.textMuted) }, + description = "${workspace.serverRef.badgeLabel} ${workspace.serverRef.displayName} · " + + "${workspace.openTabCount} open", + icon = { + Icon( + Icons.Default.Folder, + contentDescription = null, + tint = LocalOpenCodeTheme.current.textMuted, + ) + }, onClick = onClick, testTag = "home_workspace_${workspace.serverRef.endpointKey}_${workspace.workspaceKey.displayLabel()}", ) @@ -256,12 +310,10 @@ private fun HomeActionRow( } @Composable -private fun WorkspaceDetail( +private fun workspaceDetail( workspace: WorkspaceSummary, openWork: List, - onBack: () -> Unit, - onOpenFiles: () -> Unit, - onOpenTerminal: () -> Unit, + actions: WorkspaceDetailActions, modifier: Modifier = Modifier, ) { val theme = LocalOpenCodeTheme.current @@ -276,12 +328,13 @@ private fun WorkspaceDetail( label = "← Home", description = "Back to all workspaces", icon = { Icon(Icons.Default.Home, contentDescription = null, tint = theme.textMuted) }, - onClick = onBack, + onClick = actions.onBack, testTag = "home_workspace_detail_back", ) HomeSection( title = workspace.workspaceKey.displayLabel(), - body = "${workspace.serverRef.displayName} · ${workspace.workspaceKey.detailLabel()}", + body = "${workspace.serverRef.badgeLabel} ${workspace.serverRef.displayName} · " + + workspace.workspaceKey.detailLabel(), ) HomeSection( title = "Open in this workspace", @@ -291,7 +344,7 @@ private fun WorkspaceDetail( label = "Browse filtered sessions", description = "Use Sessions search/actions scoped to this workspace.", icon = { Icon(Icons.Default.ViewList, contentDescription = null, tint = theme.textMuted) }, - onClick = onBack, + onClick = actions.onBrowseSessions, testTag = "home_workspace_detail_sessions", ) Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { @@ -299,7 +352,7 @@ private fun WorkspaceDetail( label = "+ Files", description = "Focus or create Files for this workspace.", icon = { Icon(Icons.Default.Folder, contentDescription = null, tint = theme.textMuted) }, - onClick = onOpenFiles, + onClick = actions.onOpenFiles, testTag = "home_workspace_detail_files", modifier = Modifier.weight(1f), ) @@ -307,7 +360,7 @@ private fun WorkspaceDetail( label = "+ Terminal", description = "Create Terminal here.", icon = { Icon(Icons.Default.Terminal, contentDescription = null, tint = theme.textMuted) }, - onClick = onOpenTerminal, + onClick = actions.onOpenTerminal, testTag = "home_workspace_detail_terminal", modifier = Modifier.weight(1f), ) @@ -315,6 +368,11 @@ private fun WorkspaceDetail( } } +private fun WorkspaceSummary.toStartWorkTarget(): StartWorkTarget = StartWorkTarget( + serverRef = serverRef, + workspaceKey = workspaceKey, +) + private fun WorkspaceKey.displayLabel(): String = when (this) { WorkspaceKey.Global -> "Global workspace" is WorkspaceKey.Directory -> value.trimEnd('/').substringAfterLast('/').ifBlank { value } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerScreen.kt index 1f40af4f..df8e9226 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerScreen.kt @@ -545,7 +545,12 @@ private fun SavedServersSection( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.lg), ) { - Text("●", color = theme.success, fontFamily = FontFamily.Monospace) + Text( + text = "[${server.badgeLabel}]", + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = theme.textMuted, + ) Column(modifier = Modifier.weight(1f)) { Text( text = server.displayName, diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerViewModel.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerViewModel.kt index 8262a94f..c8f40ac9 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerViewModel.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerViewModel.kt @@ -14,6 +14,7 @@ import dev.blazelight.p4oc.core.network.MdnsDiscoveryManager import dev.blazelight.p4oc.core.network.ServerConfig import dev.blazelight.p4oc.core.network.ServerUrl import dev.blazelight.p4oc.core.security.CredentialStore +import dev.blazelight.p4oc.domain.server.ServerIdentity import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -92,7 +93,7 @@ class ServerViewModel constructor( } fun setRemoteUrl(url: String) { - _uiState.update { it.copy(remoteUrl = url, error = null) } + _uiState.update { it.copy(remoteUrl = url, serverNameCandidate = null, error = null) } } fun setUsername(username: String) { @@ -127,10 +128,11 @@ class ServerViewModel constructor( return@launch } AppLog.d(TAG, "Connecting to normalized URL: $url") + val identity = ServerIdentity.derive(url, state.serverNameCandidate) val config = ServerConfig( url = url, - name = "Remote Server", + name = identity.displayName, isLocal = false, username = state.username.takeIf { it.isNotBlank() }, allowInsecure = state.allowInsecure @@ -147,14 +149,14 @@ class ServerViewModel constructor( settingsDataStore.saveLastConnection(config, password) settingsDataStore.addRecentServer( url = url, - name = "Remote Server", + name = identity.displayName, username = state.username.takeIf { it.isNotBlank() }, password = password, allowInsecure = state.allowInsecure ) settingsDataStore.addSavedServer( url = url, - name = "Remote Server", + name = identity.displayName, username = state.username.takeIf { it.isNotBlank() }, password = password, allowInsecure = state.allowInsecure, @@ -185,6 +187,7 @@ class ServerViewModel constructor( _uiState.update { it.copy( remoteUrl = normalizedUrl, + serverNameCandidate = server.name, username = server.username ?: ServerUrl.DEFAULT_USERNAME, password = savedPassword ?: "", allowInsecure = server.allowInsecure @@ -239,6 +242,7 @@ class ServerViewModel constructor( _uiState.update { it.copy( remoteUrl = server.url, + serverNameCandidate = server.serviceName, username = ServerUrl.DEFAULT_USERNAME, password = "", allowInsecure = server.allowInsecure @@ -263,6 +267,7 @@ class ServerViewModel constructor( data class ServerUiState( val remoteUrl: String = "", + val serverNameCandidate: String? = null, val username: String = "opencode", val password: String = "", val allowInsecure: Boolean = false, diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/MainTabScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/MainTabScreen.kt index 3369f8d5..a9d6ba30 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/MainTabScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/MainTabScreen.kt @@ -38,8 +38,9 @@ import dev.blazelight.p4oc.domain.workspace.Workspace import dev.blazelight.p4oc.ui.components.TuiAlertDialog import dev.blazelight.p4oc.ui.components.TuiTextButton import dev.blazelight.p4oc.ui.navigation.Screen -import dev.blazelight.p4oc.ui.screens.home.HomeScreen +import dev.blazelight.p4oc.ui.screens.home.HomeActions import dev.blazelight.p4oc.ui.screens.home.HomeSummaryBuilder +import dev.blazelight.p4oc.ui.screens.home.homeScreen import dev.blazelight.p4oc.ui.theme.LocalOpenCodeTheme import dev.blazelight.p4oc.ui.theme.Spacing import dev.blazelight.p4oc.ui.theme.TuiShapes @@ -92,6 +93,9 @@ fun MainTabScreen( var restoreError by remember { mutableStateOf(null) } var showStartWorkSheet by remember { mutableStateOf(false) } var showFilesTabPrompt by remember { mutableStateOf(false) } + var homeDetailSelection by remember { + mutableStateOf(StartWorkSelection.NeedsSelection) + } // Foreground resume is delegated to ConnectionManager so reconnect policy // has one owner instead of competing UI timers and SSE retry callbacks. @@ -293,15 +297,10 @@ fun MainTabScreen( // Snackbar for tab warning val snackbarHostState = remember { SnackbarHostState() } - fun requestFilesTab() { - showFilesTabPrompt = true - } - - fun requestFilesTab(workspaceKey: WorkspaceKey) { - val targetServer = tabManager.activeTab?.serverRef ?: currentServerRef ?: return + fun requestFilesTab(target: StartWorkTarget) { tabManager.focusOrCreateFilesTab( - serverRef = targetServer, - workspaceKey = workspaceKey, + serverRef = target.serverRef, + workspaceKey = target.workspaceKey, ) } @@ -419,44 +418,56 @@ fun MainTabScreen( val isActive = tab.id == activeTabId val workspaceOwner = workspaceOwners[tab.id] if (tab.isPinnedHome) { - HomeScreen( + homeScreen( summary = HomeSummaryBuilder.build( savedServers = savedServers, connectionStates = homeConnectionStates, tabs = tabs, ), - onBrowseSessions = { - tabManager.createTab( - startRoute = Screen.Sessions.route, - workspaceKey = WorkspaceKey.Global, - serverRef = currentServerRef ?: return@HomeScreen, - focus = true, - ) - }, - onOpenFiles = { requestFilesTab(WorkspaceKey.Global) }, - onOpenTerminal = { - coroutineScope.launch { - val api = connectionManager.getApi() ?: run { - snackbarHostState.showSnackbar("Not connected to server") - return@launch - } - val result = safeApiCall { - api.createPtySession(createPtyRequestForWorkspace(WorkspaceKey.Global)) - } - if (result is ApiResult.Success) { - tabManager.createTab( - startRoute = Screen.Terminal.createRoute(result.data.id), - workspaceKey = WorkspaceKey.Global, - serverRef = currentServerRef ?: return@launch, - focus = true, - ) - } else if (result is ApiResult.Error) { - snackbarHostState.showSnackbar( - "Failed to create terminal: ${result.message}", - ) + actions = HomeActions( + onBrowseSessions = { target -> + tabManager.createTab( + startRoute = Screen.Sessions.route, + workspaceKey = target.workspaceKey, + serverRef = target.serverRef, + focus = true, + ) + }, + onOpenFiles = { target -> requestFilesTab(target) }, + onOpenTerminal = { target -> + coroutineScope.launch { + if (target.serverRef.endpointKey != currentServerRef?.endpointKey) { + snackbarHostState.showSnackbar( + "Select and connect to ${target.serverRef.displayName} first", + ) + return@launch + } + val api = connectionManager.getApi() ?: run { + snackbarHostState.showSnackbar("Not connected to server") + return@launch + } + val result = safeApiCall { + api.createPtySession( + createPtyRequestForWorkspace(target.workspaceKey), + ) + } + if (result is ApiResult.Success) { + tabManager.createTab( + startRoute = Screen.Terminal.createRoute(result.data.id), + workspaceKey = target.workspaceKey, + serverRef = target.serverRef, + focus = true, + ) + } else if (result is ApiResult.Error) { + snackbarHostState.showSnackbar( + "Failed to create terminal: ${result.message}", + ) + } } - } - }, + }, + onChooseTarget = { showFilesTabPrompt = true }, + onWorkspaceDetailChanged = { homeDetailSelection = it }, + ), modifier = Modifier.fillMaxSize(), ) } else if (workspaceOwner != null) { @@ -464,13 +475,19 @@ fun MainTabScreen( navController = navController, tabManager = tabManager, tabId = tab.id, - serverRef = tab.serverRef ?: currentServerRef ?: return@SaveableStateProvider, + serverRef = tab.serverRef ?: return@SaveableStateProvider, onDisconnect = onDisconnect, onCloseTab = { closeTab(tab.id) }, startRoute = tab.startRoute, workspaceOwner = workspaceOwner, onNewFilesTab = { - requestFilesTab() + val serverRef = tab.serverRef + val workspaceKey = tab.workspaceKey + if (serverRef != null && workspaceKey != null) { + requestFilesTab(StartWorkTarget(serverRef, workspaceKey)) + } else { + showFilesTabPrompt = true + } }, onNewTerminalTab = { coroutineScope.launch { @@ -495,7 +512,7 @@ fun MainTabScreen( tabManager.createTab( startRoute = Screen.Terminal.createRoute(ptyId), workspaceKey = workspaceKey, - serverRef = tab.serverRef ?: currentServerRef ?: return@launch, + serverRef = tab.serverRef ?: return@launch, focus = true, ) } @@ -532,9 +549,21 @@ fun MainTabScreen( } if (showStartWorkSheet) { - val startContext = startWorkContextFor(tabManager.activeTab) - val targetWorkspace = startContext.defaultWorkspace ?: WorkspaceKey.Global - val targetServer = startContext.defaultServer ?: currentServerRef + val availableServers = savedServers.map { + ServerRef.fromEndpointKey(it.endpointKey, it.displayName) + } + val rawContext = if (tabManager.activeTab?.isPinnedHome == true) { + StartWorkContext( + source = StartWorkSource.HomeWorkspaceDetail, + selection = homeDetailSelection, + ) + } else { + startWorkContextFor(tabManager.activeTab) + } + val startContext = rawContext.copy( + selection = rawContext.selection.validatedAgainst(availableServers), + ) + val target = startContext.selectedTarget TuiAlertDialog( onDismissRequest = { showStartWorkSheet = false }, title = "Start work", @@ -545,16 +574,12 @@ fun MainTabScreen( }, ) { Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm)) { - val targetLabel = workspaceLabel(targetWorkspace, tabTitleLabels) - ?: workspaceSubtitle(targetWorkspace) Text( - text = if (startContext.hasExplicitTarget) { - "Target: $targetLabel" - } else if (targetServer != null) { - "Target: ${targetServer.displayName} · $targetLabel" - } else { - "Choose a target before creating work." - }, + text = target?.let { + val workspace = workspaceLabel(it.workspaceKey, tabTitleLabels) + ?: workspaceSubtitle(it.workspaceKey) + "Target: ${it.serverRef.displayName} · $workspace" + } ?: "Choose a target before creating work.", color = theme.textMuted, style = MaterialTheme.typography.bodySmall, ) @@ -564,26 +589,34 @@ fun MainTabScreen( marker = "C", onClick = { showStartWorkSheet = false - val serverRef = targetServer - if (serverRef != null) { + if (target == null) { + showFilesTabPrompt = true + } else { tabManager.createTab( startRoute = Screen.Sessions.route, - workspaceKey = targetWorkspace, - serverRef = serverRef, + workspaceKey = target.workspaceKey, + serverRef = target.serverRef, focus = true, ) - } else { - showFilesTabPrompt = true } }, ) StartWorkActionRow( label = "Browse sessions", - description = "Open existing sessions in Home's current context.", + description = "Browse sessions for the exact target workspace.", marker = "S", onClick = { showStartWorkSheet = false - tabManager.ensureHomeTab(focus = true) + if (target == null) { + showFilesTabPrompt = true + } else { + tabManager.createTab( + startRoute = Screen.Sessions.route, + workspaceKey = target.workspaceKey, + serverRef = target.serverRef, + focus = true, + ) + } }, ) StartWorkActionRow( @@ -592,14 +625,7 @@ fun MainTabScreen( marker = "F", onClick = { showStartWorkSheet = false - if (targetServer != null) { - tabManager.focusOrCreateFilesTab( - serverRef = targetServer, - workspaceKey = targetWorkspace, - ) - } else { - showFilesTabPrompt = true - } + if (target == null) showFilesTabPrompt = true else requestFilesTab(target) }, ) StartWorkActionRow( @@ -608,27 +634,33 @@ fun MainTabScreen( marker = "T", onClick = { showStartWorkSheet = false - coroutineScope.launch { - val api = connectionManager.getApi() ?: run { - snackbarHostState.showSnackbar("Not connected to server") - return@launch - } - val serverRef = targetServer ?: run { - snackbarHostState.showSnackbar("No server target selected") - return@launch - } - val result = safeApiCall { - api.createPtySession(createPtyRequestForWorkspace(targetWorkspace)) - } - if (result is ApiResult.Success) { - tabManager.createTab( - startRoute = Screen.Terminal.createRoute(result.data.id), - workspaceKey = targetWorkspace, - serverRef = serverRef, - focus = true, - ) - } else if (result is ApiResult.Error) { - snackbarHostState.showSnackbar("Failed to create terminal: ${result.message}") + if (target == null) { + showFilesTabPrompt = true + } else { + coroutineScope.launch { + if (target.serverRef.endpointKey != currentServerRef?.endpointKey) { + snackbarHostState.showSnackbar( + "Select and connect to ${target.serverRef.displayName} first", + ) + return@launch + } + val api = connectionManager.getApi() ?: run { + snackbarHostState.showSnackbar("Not connected to server") + return@launch + } + val result = safeApiCall { + api.createPtySession(createPtyRequestForWorkspace(target.workspaceKey)) + } + if (result is ApiResult.Success) { + tabManager.createTab( + startRoute = Screen.Terminal.createRoute(result.data.id), + workspaceKey = target.workspaceKey, + serverRef = target.serverRef, + focus = true, + ) + } else if (result is ApiResult.Error) { + snackbarHostState.showSnackbar("Failed to create terminal: ${result.message}") + } } } }, @@ -647,39 +679,48 @@ fun MainTabScreen( } if (showFilesTabPrompt) { - val openWorkspaceKeys = tabs - .mapNotNull { it.workspaceKey } + val availableEndpointKeys = savedServers.mapTo(mutableSetOf()) { it.endpointKey } + val openTargets = tabs.mapNotNull { tab -> + val serverRef = tab.serverRef ?: return@mapNotNull null + val workspaceKey = tab.workspaceKey ?: return@mapNotNull null + StartWorkTarget(serverRef, workspaceKey) + }.filter { it.serverRef.endpointKey in availableEndpointKeys } .distinct() - fun openFilesTab(workspaceKey: WorkspaceKey) { - tabManager.focusOrCreateFilesTab( - serverRef = currentServerRef ?: return, - workspaceKey = workspaceKey, + val noProjectTargets = savedServers.map { + StartWorkTarget( + ServerRef.fromEndpointKey(it.endpointKey, it.displayName), + WorkspaceKey.Global, ) + } + fun selectTarget(target: StartWorkTarget) { + requestFilesTab(target) showFilesTabPrompt = false } TuiAlertDialog( onDismissRequest = { showFilesTabPrompt = false }, - title = "Select Files workspace", + title = "Select workspace", confirmButton = { TuiTextButton(onClick = { showFilesTabPrompt = false }) { Text("Cancel") } } ) { - Text("Open a Files tab for:") - FilesWorkspaceOption( - title = "Global files", - subtitle = "No project context", - marker = "◆", - onClick = { openFilesTab(WorkspaceKey.Global) }, - ) - openWorkspaceKeys.forEach { workspaceKey -> + Text("Choose an exact server and workspace:") + noProjectTargets.forEach { target -> + FilesWorkspaceOption( + title = "${target.serverRef.displayName} · No project context", + subtitle = "Explicit server scope", + marker = "◆", + onClick = { selectTarget(target) }, + ) + } + openTargets.filter { it.workspaceKey != WorkspaceKey.Global }.forEach { target -> FilesWorkspaceOption( - title = workspaceLabel(workspaceKey, tabTitleLabels) ?: "Missing workspace", - subtitle = workspaceSubtitle(workspaceKey), + title = workspaceLabel(target.workspaceKey, tabTitleLabels) ?: "Missing workspace", + subtitle = "${target.serverRef.displayName} · ${workspaceSubtitle(target.workspaceKey)}", marker = "◇", - onClick = { openFilesTab(workspaceKey) }, + onClick = { selectTarget(target) }, ) } } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/StartWorkContext.kt b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/StartWorkContext.kt index 6d72c2f1..d8c4d4c7 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/StartWorkContext.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/StartWorkContext.kt @@ -3,13 +3,33 @@ package dev.blazelight.p4oc.ui.tabs import dev.blazelight.p4oc.domain.server.ServerRef import dev.blazelight.p4oc.domain.server.WorkspaceKey +data class StartWorkTarget( + val serverRef: ServerRef, + val workspaceKey: WorkspaceKey, +) + +sealed interface StartWorkSelection { + data class Selected(val target: StartWorkTarget) : StartWorkSelection + data object NeedsSelection : StartWorkSelection +} + data class StartWorkContext( val source: StartWorkSource, - val defaultServer: ServerRef?, - val defaultWorkspace: WorkspaceKey?, + val selection: StartWorkSelection, val defaultAction: StartWorkAction? = null, ) { - val hasExplicitTarget: Boolean get() = defaultServer != null && defaultWorkspace != null + val selectedTarget: StartWorkTarget? + get() = (selection as? StartWorkSelection.Selected)?.target +} +fun StartWorkSelection.validatedAgainst(availableServers: Collection): StartWorkSelection = when (this) { + StartWorkSelection.NeedsSelection -> this + is StartWorkSelection.Selected -> if ( + availableServers.any { it.endpointKey == target.serverRef.endpointKey } + ) { + this + } else { + StartWorkSelection.NeedsSelection + } } enum class StartWorkSource { @@ -33,18 +53,32 @@ fun startWorkContextFor(tab: TabInstance?): StartWorkContext { if (tab == null || tab.isPinnedHome) { return StartWorkContext( source = StartWorkSource.HomeTopLevel, - defaultServer = null, - defaultWorkspace = null, + selection = StartWorkSelection.NeedsSelection, defaultAction = StartWorkAction.ChooseAnotherTarget, ) } + val serverRef = tab.serverRef + val workspaceKey = tab.workspaceKey return StartWorkContext( source = sourceForRoute(tab.startRoute), - defaultServer = tab.serverRef, - defaultWorkspace = tab.workspaceKey, + selection = if (serverRef != null && workspaceKey != null) { + StartWorkSelection.Selected(StartWorkTarget(serverRef, workspaceKey)) + } else { + StartWorkSelection.NeedsSelection + }, + defaultAction = if (serverRef == null || workspaceKey == null) { + StartWorkAction.ChooseAnotherTarget + } else { + null + }, ) } +fun startWorkContextForHomeDetail(target: StartWorkTarget): StartWorkContext = StartWorkContext( + source = StartWorkSource.HomeWorkspaceDetail, + selection = StartWorkSelection.Selected(target), +) + private fun sourceForRoute(route: String): StartWorkSource = when { route.startsWith("chat/") -> StartWorkSource.ChatTab route.startsWith("files") -> StartWorkSource.FilesTab diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabBar.kt b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabBar.kt index 30bbf720..bf27090b 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabBar.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabBar.kt @@ -210,6 +210,7 @@ fun getIconForRoute(route: String?): ImageVector { */ data class TabTitleLabels( val fallbackTab: String, + val home: String, val sessions: String, val chat: String, val files: String, @@ -224,6 +225,7 @@ data class TabTitleLabels( @Composable fun rememberTabTitleLabels(): TabTitleLabels = TabTitleLabels( fallbackTab = stringResource(R.string.tab_title_fallback), + home = stringResource(R.string.home_title), sessions = stringResource(R.string.sessions_title), chat = stringResource(R.string.tab_title_chat), files = stringResource(R.string.tab_title_files), @@ -243,6 +245,7 @@ fun getTitleForRoute( ): String { return when { route == null -> labels.fallbackTab + route == "home" -> labels.home route == "sessions" -> withWorkspaceSuffix(labels.sessions, workspaceKey, labels) route.startsWith("sessions?") -> withWorkspaceSuffix(labels.sessions, workspaceKey, labels) route.startsWith("chat/") -> withWorkspaceSuffix(sessionTitle ?: labels.chat, workspaceKey, labels) diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabNavHost.kt b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabNavHost.kt index 0ba36337..28ea8d2e 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabNavHost.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabNavHost.kt @@ -35,8 +35,9 @@ import dev.blazelight.p4oc.ui.screens.diff.SessionDiffScreen import dev.blazelight.p4oc.ui.screens.files.FileExplorerScreen import dev.blazelight.p4oc.ui.screens.files.FileViewerScreen import dev.blazelight.p4oc.ui.screens.files.FilesViewModel -import dev.blazelight.p4oc.ui.screens.home.HomeScreen +import dev.blazelight.p4oc.ui.screens.home.HomeActions import dev.blazelight.p4oc.ui.screens.home.HomeSummaryBuilder +import dev.blazelight.p4oc.ui.screens.home.homeScreen import dev.blazelight.p4oc.ui.screens.projects.ProjectsScreen import dev.blazelight.p4oc.ui.screens.sessions.SessionListScreen import dev.blazelight.p4oc.ui.screens.sessions.SessionListViewModel @@ -173,15 +174,18 @@ fun TabNavHost( ) ) { composable(Screen.Home.route) { - HomeScreen( + homeScreen( summary = HomeSummaryBuilder.build( savedServers = savedServers, connectionStates = homeConnectionStates, tabs = tabs, ), - onBrowseSessions = { navController.navigate(Screen.Sessions.route) }, - onOpenFiles = onNewFilesTab, - onOpenTerminal = onNewTerminalTab, + actions = HomeActions( + onBrowseSessions = { navController.navigate(Screen.Sessions.route) }, + onOpenFiles = { onNewFilesTab() }, + onOpenTerminal = { onNewTerminalTab() }, + onChooseTarget = onNewFilesTab, + ), ) } @@ -201,26 +205,28 @@ fun TabNavHost( keySuffix = "sessions", ), onSessionClick = { sessionId, directory -> - // Check if session already open in another tab + val selection = directory.toNavigationWorkspaceSelection() + ?: return@SessionListScreen val existingTab = tabManager.findTabBySessionId(sessionId) if (existingTab != null && existingTab.id != tabId) { - // Focus existing tab tabManager.focusTab(existingTab.id) } else { val chatRoute = Screen.Chat.createRoute(sessionId) - if (directory != workspaceOwner.workspace.directory) { + if (selection.directory != workspaceOwner.workspace.directory) { pendingRoute = chatRoute - tabManager.updateTabWorkspace(tabId, directory.toWorkspaceKey()) + tabManager.updateTabWorkspace(tabId, selection.workspaceKey) } else { navController.navigate(chatRoute) } } }, onNewSession = { sessionId, directory -> + val selection = directory.toNavigationWorkspaceSelection() + ?: return@SessionListScreen val chatRoute = Screen.Chat.createRoute(sessionId) - if (directory != workspaceOwner.workspace.directory) { + if (selection.directory != workspaceOwner.workspace.directory) { pendingRoute = chatRoute - tabManager.updateTabWorkspace(tabId, directory.toWorkspaceKey()) + tabManager.updateTabWorkspace(tabId, selection.workspaceKey) } else { navController.navigate(chatRoute) } @@ -238,9 +244,11 @@ fun TabNavHost( navController.navigate(Screen.SessionDiff.createRoute(sessionId)) }, onCreateSessionInWorkspace = { title, directory -> - pendingSessionCreate = PendingSessionCreate(title, directory) - if (directory != workspaceOwner.workspace.directory) { - tabManager.updateTabWorkspace(tabId, directory.toWorkspaceKey()) + val selection = directory.toNavigationWorkspaceSelection() + ?: return@SessionListScreen + pendingSessionCreate = PendingSessionCreate(title, selection.directory) + if (selection.directory != workspaceOwner.workspace.directory) { + tabManager.updateTabWorkspace(tabId, selection.workspaceKey) } }, autoCreateSession = pendingSessionCreate != null && @@ -276,24 +284,28 @@ fun TabNavHost( ), filterProjectId = projectId, onSessionClick = { sessionId, directory -> + val selection = directory.toNavigationWorkspaceSelection() + ?: return@SessionListScreen val existingTab = tabManager.findTabBySessionId(sessionId) if (existingTab != null && existingTab.id != tabId) { tabManager.focusTab(existingTab.id) } else { val chatRoute = Screen.Chat.createRoute(sessionId) - if (directory != workspaceOwner.workspace.directory) { + if (selection.directory != workspaceOwner.workspace.directory) { pendingRoute = chatRoute - tabManager.updateTabWorkspace(tabId, directory.toWorkspaceKey()) + tabManager.updateTabWorkspace(tabId, selection.workspaceKey) } else { navController.navigate(chatRoute) } } }, onNewSession = { sessionId, directory -> + val selection = directory.toNavigationWorkspaceSelection() + ?: return@SessionListScreen val chatRoute = Screen.Chat.createRoute(sessionId) - if (directory != workspaceOwner.workspace.directory) { + if (selection.directory != workspaceOwner.workspace.directory) { pendingRoute = chatRoute - tabManager.updateTabWorkspace(tabId, directory.toWorkspaceKey()) + tabManager.updateTabWorkspace(tabId, selection.workspaceKey) } else { navController.navigate(chatRoute) } @@ -311,10 +323,12 @@ fun TabNavHost( navController.navigate(Screen.SessionDiff.createRoute(sessionId)) }, onCreateSessionInWorkspace = { title, directory -> - pendingSessionCreate = PendingSessionCreate(title, directory) - if (directory != workspaceOwner.workspace.directory) { + val selection = directory.toNavigationWorkspaceSelection() + ?: return@SessionListScreen + pendingSessionCreate = PendingSessionCreate(title, selection.directory) + if (selection.directory != workspaceOwner.workspace.directory) { pendingRoute = Screen.SessionsFiltered.createRoute(projectId) - tabManager.updateTabWorkspace(tabId, directory.toWorkspaceKey()) + tabManager.updateTabWorkspace(tabId, selection.workspaceKey) } }, autoCreateSession = pendingSessionCreate != null && @@ -403,7 +417,7 @@ fun TabNavHost( val filteredRoute = Screen.SessionsFiltered.createRoute(projectId) if (worktree != workspaceOwner.workspace.directory) { pendingRoute = filteredRoute - tabManager.updateTabWorkspace(tabId, worktree.toWorkspaceKey()) + tabManager.updateTabWorkspace(tabId, WorkspaceKey.Directory(worktree)) } else { navController.navigate(filteredRoute) } @@ -687,7 +701,22 @@ private fun filesViewModelForRoute( parameters = { parametersOf(workspaceViewModel.fileRepository, workspaceViewModel.uploadCoordinator) }, ) -private fun String?.toWorkspaceKey(): WorkspaceKey = this - ?.takeIf { it.isNotBlank() } - ?.let(WorkspaceKey::Directory) - ?: WorkspaceKey.Global +private sealed interface NavigationWorkspaceSelection { + val workspaceKey: WorkspaceKey + val directory: String? + + data object NoProjectContext : NavigationWorkspaceSelection { + override val workspaceKey = WorkspaceKey.Global + override val directory: String? = null + } + + data class Directory(override val directory: String) : NavigationWorkspaceSelection { + override val workspaceKey = WorkspaceKey.Directory(directory) + } +} + +private fun String?.toNavigationWorkspaceSelection(): NavigationWorkspaceSelection? = when { + this == null -> NavigationWorkspaceSelection.NoProjectContext + isBlank() -> null + else -> NavigationWorkspaceSelection.Directory(this) +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index afb0bac3..c3dc4d31 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,6 +1,7 @@ OpenCode + Home Connect to Server @@ -9,7 +10,7 @@ Remote Local Server (Termux) Run OpenCode directly on your device using Termux. - Remote Server + Network Server Connect to an OpenCode server running on your network. Recent Servers Saved servers diff --git a/app/src/test/java/dev/blazelight/p4oc/core/datastore/SavedServerRegistryTest.kt b/app/src/test/java/dev/blazelight/p4oc/core/datastore/SavedServerRegistryTest.kt index 1eb4e362..a70347ba 100644 --- a/app/src/test/java/dev/blazelight/p4oc/core/datastore/SavedServerRegistryTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/core/datastore/SavedServerRegistryTest.kt @@ -18,7 +18,7 @@ class SavedServerRegistryTest { assertEquals("https://my-host.example.com", server.endpoint) assertEquals("https://my-host.example.com:4096", server.endpointKey) assertEquals(server.endpointKey, server.id) - assertEquals("Remote", server.displayName) + assertEquals("my-host.example.com", server.displayName) assertEquals("opencode", server.username) } @@ -96,6 +96,43 @@ class SavedServerRegistryTest { assertTrue(migrated.any { it.displayName == "Beta recent" }) } + @Test + fun `normalize migrates legacy generic name without changing durable endpoint id`() { + val legacy = SavedServer( + id = "https://build-box.local:4096", + endpoint = "https://build-box.local", + endpointKey = "https://build-box.local:4096", + displayName = "Remote Server", + pinned = true, + ) + + val migrated = SavedServerRegistry.normalize(legacy) + + assertEquals(legacy.id, migrated.id) + assertEquals(legacy.endpointKey, migrated.endpointKey) + assertEquals("build-box.local", migrated.displayName) + assertEquals(migrated.badgeLabel, SavedServerRegistry.normalize(migrated).badgeLabel) + assertTrue(migrated.pinned) + } + + @Test + fun `rename preserves endpoint identity while updating reusable badge identity`() { + val original = SavedServerRegistry.fromConnection( + url = "https://build-box.local", + name = "Build Box", + ) + val renamed = SavedServerRegistry.upsert( + current = listOf(original), + server = original.copy(displayName = "Jasmin Workstation"), + ).single() + + assertEquals(original.id, renamed.id) + assertEquals(original.endpointKey, renamed.endpointKey) + assertEquals("Jasmin Workstation", renamed.displayName) + assertTrue(original.badgeLabel.startsWith("BB")) + assertTrue(renamed.badgeLabel.startsWith("JW")) + } + @Test fun `saved server model has no password or api key field`() { val propertyNames = SavedServer::class.java.declaredFields.map { it.name } diff --git a/app/src/test/java/dev/blazelight/p4oc/domain/server/ServerIdentityTest.kt b/app/src/test/java/dev/blazelight/p4oc/domain/server/ServerIdentityTest.kt new file mode 100644 index 00000000..35d46da0 --- /dev/null +++ b/app/src/test/java/dev/blazelight/p4oc/domain/server/ServerIdentityTest.kt @@ -0,0 +1,80 @@ +package dev.blazelight.p4oc.domain.server + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +class ServerIdentityTest { + @Test + fun `meaningful discovery name wins over endpoint hostname`() { + val identity = ServerIdentity.derive( + endpoint = "https://build-box.local:4096", + candidateName = " Jasmin Workstation ", + ) + + assertEquals("Jasmin Workstation", identity.displayName) + assertEquals("JWMX", identity.badgeLabel) + } + + @Test + fun `generic legacy names migrate to recognizable hostname identity`() { + listOf("Remote Server", "Remote", "Server", "OpenCode Server", " ").forEach { legacyName -> + val identity = ServerIdentity.derive("https://build-box.local:4096", legacyName) + + assertEquals("build-box.local", identity.displayName) + assertTrue(identity.badgeLabel.startsWith("BB")) + } + } + + @Test + fun `numeric endpoint falls back to host and port so targets remain distinguishable`() { + val first = ServerIdentity.derive("http://192.168.1.4:4096") + val second = ServerIdentity.derive("http://192.168.1.4:5096") + + assertEquals("192.168.1.4:4096", first.displayName) + assertEquals("192.168.1.4:5096", second.displayName) + assertNotEquals(first.badgeLabel, second.badgeLabel) + } + + @Test + fun `badge is stable across equivalent endpoint spellings and reload`() { + val original = ServerIdentity.derive("BUILD-BOX.local", "Jasmin Workstation") + val normalizedReload = ServerIdentity.derive( + "http://build-box.local:4096/?ignored=true#fragment", + "Jasmin Workstation", + ) + + assertEquals(original, normalizedReload) + } + + @Test + fun `same human initials on different servers have disambiguated badges`() { + val alpha = ServerIdentity.derive("http://alpha.example:4096", "Build Box") + val beta = ServerIdentity.derive("http://beta.example:4096", "Build Box") + + assertTrue(alpha.badgeLabel.startsWith("BB")) + assertTrue(beta.badgeLabel.startsWith("BB")) + assertNotEquals(alpha.badgeLabel, beta.badgeLabel) + } + + @Test + fun `rename updates human badge stem without changing canonical endpoint ownership`() { + val before = ServerRef.fromEndpoint("build-box.local", "Build Box") + val after = ServerRef.fromEndpoint("http://build-box.local:4096", "Jasmin Workstation") + + assertEquals(before, after) + assertEquals(before.hashCode(), after.hashCode()) + assertTrue(before.badgeLabel.startsWith("BB")) + assertTrue(after.badgeLabel.startsWith("JW")) + assertNotEquals(before.badgeLabel, after.badgeLabel) + } + + @Test + fun `invalid endpoint cannot manufacture a server identity`() { + assertThrows(IllegalArgumentException::class.java) { + ServerIdentity.derive("ftp://build-box.local", "Build Box") + } + } +} diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/tabs/StartWorkContextTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/tabs/StartWorkContextTest.kt index 768550ea..1f45f22b 100644 --- a/app/src/test/java/dev/blazelight/p4oc/ui/tabs/StartWorkContextTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/ui/tabs/StartWorkContextTest.kt @@ -4,55 +4,112 @@ import dev.blazelight.p4oc.domain.server.ServerRef import dev.blazelight.p4oc.domain.server.WorkspaceKey import dev.blazelight.p4oc.ui.navigation.Screen import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse import org.junit.Assert.assertNull +import org.junit.Assert.assertSame import org.junit.Assert.assertTrue import org.junit.Test class StartWorkContextTest { - private val server = ServerRef.fromEndpointKey("http://alpha.example:4096") + private val alpha = ServerRef.fromEndpointKey("http://alpha.example:4096") + private val beta = ServerRef.fromEndpointKey("http://beta.example:4096") private val workspace = WorkspaceKey.Directory("/repo") @Test - fun `Home defaults to target picker with no implicit server or workspace`() { + fun `Home with no project context requires explicit selection`() { val context = startWorkContextFor(TabInstance.home()) assertEquals(StartWorkSource.HomeTopLevel, context.source) - assertNull(context.defaultServer) - assertNull(context.defaultWorkspace) + assertSame(StartWorkSelection.NeedsSelection, context.selection) + assertNull(context.selectedTarget) assertEquals(StartWorkAction.ChooseAnotherTarget, context.defaultAction) - assertFalse(context.hasExplicitTarget) } @Test - fun `chat tab defaults to the tab server and workspace`() { - val tab = TabInstance( - state = TabState(workspaceKey = workspace, serverRef = server, sessionId = "s1"), - startRoute = Screen.Chat.createRoute("s1"), + fun `missing owner is rejected instead of partially defaulting`() { + val missingServer = TabInstance( + state = TabState(workspaceKey = workspace, serverRef = null), + startRoute = Screen.Files.route, ) + val missingWorkspace = TabInstance( + state = TabState(workspaceKey = null, serverRef = alpha), + startRoute = Screen.Files.route, + ) + + listOf(missingServer, missingWorkspace).forEach { tab -> + val context = startWorkContextFor(tab) + assertSame(StartWorkSelection.NeedsSelection, context.selection) + assertNull(context.selectedTarget) + assertEquals(StartWorkAction.ChooseAnotherTarget, context.defaultAction) + } + } + + @Test + fun `same directory on different servers remains differently owned`() { + val alphaContext = startWorkContextFor( + TabInstance(TabState(workspaceKey = workspace, serverRef = alpha), Screen.Files.route), + ) + val betaContext = startWorkContextFor( + TabInstance(TabState(workspaceKey = workspace, serverRef = beta), Screen.Files.route), + ) + + assertEquals(StartWorkTarget(alpha, workspace), alphaContext.selectedTarget) + assertEquals(StartWorkTarget(beta, workspace), betaContext.selectedTarget) + assertTrue(alphaContext.selectedTarget != betaContext.selectedTarget) + } + + @Test + fun `explicit no-project context is preserved as selected global target`() { + val noProjectTarget = StartWorkTarget(alpha, WorkspaceKey.Global) - val context = startWorkContextFor(tab) + val context = startWorkContextForHomeDetail(noProjectTarget) - assertEquals(StartWorkSource.ChatTab, context.source) - assertEquals(server, context.defaultServer) - assertEquals(workspace, context.defaultWorkspace) - assertTrue(context.hasExplicitTarget) + assertEquals(StartWorkSource.HomeWorkspaceDetail, context.source) + assertEquals(noProjectTarget, context.selectedTarget) + assertNull(context.defaultAction) } @Test - fun `files and terminal tabs default to their tab target`() { + fun `removed server invalidates selection instead of retargeting same directory`() { + val selection = StartWorkSelection.Selected(StartWorkTarget(alpha, workspace)) + + val validated = selection.validatedAgainst(listOf(beta)) + + assertSame(StartWorkSelection.NeedsSelection, validated) + } + + @Test + fun `explicit no-project selection survives validation when its server remains saved`() { + val selection = StartWorkSelection.Selected(StartWorkTarget(alpha, WorkspaceKey.Global)) + + val validated = selection.validatedAgainst(listOf(alpha, beta)) + + assertEquals(selection, validated) + } + + @Test + fun `chat files and terminal preserve their exact immutable owner`() { + val chat = startWorkContextFor( + TabInstance( + state = TabState(workspaceKey = workspace, serverRef = alpha, sessionId = "s1"), + startRoute = Screen.Chat.createRoute("s1"), + ), + ) val files = startWorkContextFor( - TabInstance(TabState(workspaceKey = workspace, serverRef = server), Screen.Files.route), + TabInstance(TabState(workspaceKey = workspace, serverRef = alpha), Screen.Files.route), ) val terminal = startWorkContextFor( - TabInstance(TabState(workspaceKey = workspace, serverRef = server), Screen.Terminal.createRoute("pty-1")), + TabInstance( + TabState(workspaceKey = workspace, serverRef = alpha), + Screen.Terminal.createRoute("pty-1"), + ), ) + val target = StartWorkTarget(alpha, workspace) + assertEquals(StartWorkSource.ChatTab, chat.source) + assertEquals(target, chat.selectedTarget) assertEquals(StartWorkSource.FilesTab, files.source) - assertEquals(server, files.defaultServer) - assertEquals(workspace, files.defaultWorkspace) + assertEquals(target, files.selectedTarget) assertEquals(StartWorkSource.TerminalTab, terminal.source) - assertEquals(server, terminal.defaultServer) - assertEquals(workspace, terminal.defaultWorkspace) + assertEquals(target, terminal.selectedTarget) } } diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/tabs/TabBarTitleTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/tabs/TabBarTitleTest.kt index 434515ea..15c37ef3 100644 --- a/app/src/test/java/dev/blazelight/p4oc/ui/tabs/TabBarTitleTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/ui/tabs/TabBarTitleTest.kt @@ -7,6 +7,7 @@ import org.junit.Test class TabBarTitleTest { private val labels = TabTitleLabels( + home = "Home", fallbackTab = "Tab", sessions = "Sessions", chat = "Chat", From 5d8557f9819a64e757a5e2630a8d38912ec7c521 Mon Sep 17 00:00:00 2001 From: Jasmin Le Roux Date: Tue, 14 Jul 2026 19:59:13 +0200 Subject: [PATCH 20/22] snapshot --- .tickets/oa-78mo.md | 2 +- .tickets/oa-agea.md | 2 +- .tickets/oa-cxp9.md | 2 +- .tickets/oa-hrtb.md | 2 +- .tickets/oa-iq18.md | 2 +- .tickets/oa-runv.md | 8 +- .tickets/oa-sa63.md | 4 + .tickets/oa-xyj0.md | 2 +- .tickets/oa-yujn.md | 2 +- .tickets/oa-zemb.md | 8 +- .../p4oc/core/datastore/SettingsDataStore.kt | 1 + .../p4oc/core/network/ConnectionManager.kt | 2 +- .../core/network/ServerConnectionRegistry.kt | 21 +- .../data/files/ofish/OfishSessionFactory.kt | 10 +- .../data/session/SessionRepositoryImpl.kt | 11 +- .../dev/blazelight/p4oc/di/KoinModules.kt | 27 +- .../ui/components/chat/FilePickerDialog.kt | 10 +- .../ui/components/command/CommandPalette.kt | 2 +- .../components/status/ServerStatusVisual.kt | 88 + .../components/toolwidgets/ToolGroupWidget.kt | 12 +- .../blazelight/p4oc/ui/navigation/NavGraph.kt | 24 +- .../blazelight/p4oc/ui/navigation/Screen.kt | 1 + .../p4oc/ui/preview/ComponentPreviews.kt | 1 - .../p4oc/ui/screens/chat/ChatScreen.kt | 226 +-- .../ui/screens/files/FileExplorerScreen.kt | 8 +- .../p4oc/ui/screens/home/HomeScreen.kt | 1030 ++++++++--- .../p4oc/ui/screens/home/HomeSummary.kt | 246 ++- .../p4oc/ui/screens/server/ServerScreen.kt | 1362 +++++++++------ .../p4oc/ui/screens/server/ServerViewModel.kt | 130 +- .../ui/screens/sessions/SessionListScreen.kt | 329 +--- .../screens/sessions/SessionListViewModel.kt | 48 +- .../screens/settings/VisualSettingsScreen.kt | 1 - .../blazelight/p4oc/ui/tabs/MainTabScreen.kt | 1532 ++++++++++------- .../p4oc/ui/tabs/StartWorkContext.kt | 55 +- .../dev/blazelight/p4oc/ui/tabs/TabBar.kt | 224 ++- .../dev/blazelight/p4oc/ui/tabs/TabManager.kt | 32 +- .../dev/blazelight/p4oc/ui/tabs/TabNavHost.kt | 131 +- .../blazelight/p4oc/ui/theme/ProjectColors.kt | 8 +- .../p4oc/ui/theme/opencode/FallbackTheme.kt | 3 +- .../ui/workspace/WorkspaceRepositoryOwner.kt | 4 + app/src/main/res/values/strings.xml | 54 +- .../network/ServerConnectionRegistryTest.kt | 75 +- .../data/session/SessionRepositoryImplTest.kt | 19 + .../code/TextMateAnnotatedStringTest.kt | 6 +- .../p4oc/ui/screens/chat/ChatViewModelTest.kt | 2 - .../ui/screens/home/HomeSummaryBuilderTest.kt | 374 +++- .../p4oc/ui/tabs/StartWorkContextTest.kt | 136 +- .../p4oc/ui/tabs/TabBarTitleTest.kt | 64 + .../p4oc/ui/tabs/TabManagerPersistenceTest.kt | 75 +- 49 files changed, 4376 insertions(+), 2042 deletions(-) create mode 100644 app/src/main/java/dev/blazelight/p4oc/ui/components/status/ServerStatusVisual.kt diff --git a/.tickets/oa-78mo.md b/.tickets/oa-78mo.md index fe6a4a20..35c43b08 100644 --- a/.tickets/oa-78mo.md +++ b/.tickets/oa-78mo.md @@ -1,6 +1,6 @@ --- id: oa-78mo -status: open +status: closed deps: [oa-zmqg, oa-sa63] links: [] created: 2026-07-09T19:49:06Z diff --git a/.tickets/oa-agea.md b/.tickets/oa-agea.md index 41e4bcea..d82ad8be 100644 --- a/.tickets/oa-agea.md +++ b/.tickets/oa-agea.md @@ -1,6 +1,6 @@ --- id: oa-agea -status: open +status: closed deps: [oa-zmqg, oa-sa63] links: [] created: 2026-07-09T19:48:26Z diff --git a/.tickets/oa-cxp9.md b/.tickets/oa-cxp9.md index 6aa2f9d2..f7e1b4de 100644 --- a/.tickets/oa-cxp9.md +++ b/.tickets/oa-cxp9.md @@ -1,6 +1,6 @@ --- id: oa-cxp9 -status: open +status: closed deps: [oa-zmqg, oa-sa63] links: [] created: 2026-07-09T19:48:46Z diff --git a/.tickets/oa-hrtb.md b/.tickets/oa-hrtb.md index 140169f1..2d64b976 100644 --- a/.tickets/oa-hrtb.md +++ b/.tickets/oa-hrtb.md @@ -1,6 +1,6 @@ --- id: oa-hrtb -status: open +status: closed deps: [oa-zmqg] links: [] created: 2026-07-09T19:48:57Z diff --git a/.tickets/oa-iq18.md b/.tickets/oa-iq18.md index 9bc7deb6..7fe40df6 100644 --- a/.tickets/oa-iq18.md +++ b/.tickets/oa-iq18.md @@ -1,6 +1,6 @@ --- id: oa-iq18 -status: open +status: closed deps: [oa-zmqg, oa-sa63] links: [] created: 2026-07-09T19:48:36Z diff --git a/.tickets/oa-runv.md b/.tickets/oa-runv.md index 34651c32..51e463c9 100644 --- a/.tickets/oa-runv.md +++ b/.tickets/oa-runv.md @@ -1,6 +1,6 @@ --- id: oa-runv -status: open +status: closed deps: [oa-cxp9, oa-agea] links: [] created: 2026-07-09T15:15:06Z @@ -81,3 +81,9 @@ Start Work should make target explicit: - 2026-07-09: `JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew :app:compileDebugKotlin && JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew :app:detekt` passed. - 2026-07-09: Home was structurally changed toward the approved mockup: compact header, Servers cards, Resume workspace rows, Browse actions, and no persistent Attention explainer block. - 2026-07-09: Device screenshot verification is still required before closing because `adb devices` currently has no connected device. Capture Home empty, Home with open work, Start Work from Home, and Start Work from active workspace before closing. + +## Notes + +**2026-07-10T07:06:56Z** + +Device verification completed for the remaining feasible copy states: ux-final-home.png shows task-oriented empty Home without implementation phrases or attention explainer; ux-final-start-work.png clearly requests server/workspace selection; ux-final-start-work-scoped.png names endpoint plus explicit No project context and separates new from existing work. Integrated compile, detekt, and unit suite pass. diff --git a/.tickets/oa-sa63.md b/.tickets/oa-sa63.md index d3445b45..f4356c64 100644 --- a/.tickets/oa-sa63.md +++ b/.tickets/oa-sa63.md @@ -39,3 +39,7 @@ Introduce or reuse one immutable scoped identity value rather than parallel null **2026-07-09T20:11:48Z** Implemented typed StartWorkTarget/StartWorkSelection and NavigationWorkspaceSelection contracts. Home detail and Start Work actions carry exact ServerRef + WorkspaceKey; ambiguous or removed owners require selection; No project context is explicit; blank directories reject instead of falling back. Added mixed-server/missing-owner/removed-server coverage. Verified :app:compileDebugKotlin, :app:detekt, and :app:testDebugUnitTest pass. Pre-existing nullable/global semantics outside this ticket remain in persistence/network boundary models and should be assessed separately rather than treated as this flow's fallback. + +**2026-07-10T07:06:46Z** + +Final verification: :app:compileDebugKotlin and :app:detekt passed in the integrated run; :app:testDebugUnitTest passed after Home summary migration. Device screenshots ux-final-start-work.png and ux-final-start-work-scoped.png verify ambiguous selection and explicit No project context with exact endpoint ownership; Home verifies endpoint-scoped server identity. Registry execution, persistence, and per-tab ownership tests are included in the passing suite. diff --git a/.tickets/oa-xyj0.md b/.tickets/oa-xyj0.md index 6f92362c..27bd13e3 100644 --- a/.tickets/oa-xyj0.md +++ b/.tickets/oa-xyj0.md @@ -1,6 +1,6 @@ --- id: oa-xyj0 -status: open +status: closed deps: [] links: [] created: 2026-07-09T15:15:06Z diff --git a/.tickets/oa-yujn.md b/.tickets/oa-yujn.md index 1c066520..246d6950 100644 --- a/.tickets/oa-yujn.md +++ b/.tickets/oa-yujn.md @@ -1,6 +1,6 @@ --- id: oa-yujn -status: open +status: closed deps: [] links: [] created: 2026-07-09T15:15:06Z diff --git a/.tickets/oa-zemb.md b/.tickets/oa-zemb.md index 85e5e084..7b5fe200 100644 --- a/.tickets/oa-zemb.md +++ b/.tickets/oa-zemb.md @@ -1,6 +1,6 @@ --- id: oa-zemb -status: open +status: in_progress deps: [] links: [] created: 2026-07-09T00:00:00Z @@ -66,3 +66,9 @@ Open-tab confirmation copy should explain the consequence, for example: - 2026-07-09: Implementation changed saved server rows from inline `remove` / `warn remove` text to an overflow menu with localized `Forget server` and a destructive `TuiConfirmDialog` that includes open-tab count when applicable. - 2026-07-09: `JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew :app:compileDebugKotlin && JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew :app:detekt` passed. - 2026-07-09: Device verification is still required before closing. `JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew :app:installDebug` failed with `No connected devices`, and `adb devices` returned no connected device. Do not close until the overflow -> Forget server -> confirmation dialog flow is screenshotted with an open tab referencing the saved server. + +## Notes + +**2026-07-10T07:39:56Z** + +Implementation and integrated compile/detekt/tests pass. Current-device server inventory screenshots verify the inline destructive label is gone, but the required open-tab overflow -> Forget server -> confirmation-dialog screenshot was not captured. Ticket remains in progress per its explicit verification gate. diff --git a/app/src/main/java/dev/blazelight/p4oc/core/datastore/SettingsDataStore.kt b/app/src/main/java/dev/blazelight/p4oc/core/datastore/SettingsDataStore.kt index dca8e013..2230ff0d 100644 --- a/app/src/main/java/dev/blazelight/p4oc/core/datastore/SettingsDataStore.kt +++ b/app/src/main/java/dev/blazelight/p4oc/core/datastore/SettingsDataStore.kt @@ -892,6 +892,7 @@ data class PersistedTab( fun resolvedWorkspaceKey(): WorkspaceKey? = workspaceKey?.toWorkspaceKey() fun resolvedServerEndpointKey(fallback: String? = null): String? = serverEndpointKey ?: fallback } + @Serializable data class PersistedWorkspaceKey( val type: Type, diff --git a/app/src/main/java/dev/blazelight/p4oc/core/network/ConnectionManager.kt b/app/src/main/java/dev/blazelight/p4oc/core/network/ConnectionManager.kt index 9b14f32b..b5d25df0 100644 --- a/app/src/main/java/dev/blazelight/p4oc/core/network/ConnectionManager.kt +++ b/app/src/main/java/dev/blazelight/p4oc/core/network/ConnectionManager.kt @@ -28,10 +28,10 @@ import kotlinx.serialization.json.Json import okhttp3.ConnectionPool import okhttp3.Credentials import okhttp3.Dispatcher +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.Interceptor import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit import retrofit2.converter.kotlinx.serialization.asConverterFactory diff --git a/app/src/main/java/dev/blazelight/p4oc/core/network/ServerConnectionRegistry.kt b/app/src/main/java/dev/blazelight/p4oc/core/network/ServerConnectionRegistry.kt index 9b86ac44..279526a0 100644 --- a/app/src/main/java/dev/blazelight/p4oc/core/network/ServerConnectionRegistry.kt +++ b/app/src/main/java/dev/blazelight/p4oc/core/network/ServerConnectionRegistry.kt @@ -5,10 +5,12 @@ import dev.blazelight.p4oc.core.datastore.SettingsDataStore import dev.blazelight.p4oc.domain.server.ServerRef import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import java.util.concurrent.ConcurrentHashMap @@ -26,8 +28,11 @@ class ServerConnectionRegistry constructor( private val states = ConcurrentHashMap>() private val managers = ConcurrentHashMap() private val connections = ConcurrentHashMap>() + private val stateCollectors = ConcurrentHashMap() - fun connectionState(serverRef: ServerRef): StateFlow = stateFlow(serverRef.endpointKey).asStateFlow() + fun connectionState(serverRef: ServerRef): StateFlow = stateFlow( + serverRef.endpointKey + ).asStateFlow() fun connection(serverRef: ServerRef): StateFlow = connections.getOrPut(serverRef.endpointKey) { MutableStateFlow(null).asStateFlow() @@ -35,6 +40,9 @@ class ServerConnectionRegistry constructor( fun api(serverRef: ServerRef): OpenCodeApi? = managers[serverRef.endpointKey]?.getApi() + fun generation(serverRef: ServerRef): dev.blazelight.p4oc.domain.server.ServerGeneration? = + managers[serverRef.endpointKey]?.currentGeneration + fun connect(server: SavedServer, password: String? = null) { val serverRef = server.toServerRef() val state = stateFlow(server.endpointKey) @@ -42,9 +50,17 @@ class ServerConnectionRegistry constructor( val manager = managers.getOrPut(server.endpointKey) { connectionManagerFactory(server.toServerConfig()) } + stateCollectors.computeIfAbsent(server.endpointKey) { + scope.launch { + manager.connectionState.collect { managerState -> + state.value = managerState + } + } + } connections[server.endpointKey] = manager.connection scope.launch { - val result = manager.connect(server.toServerConfig(), password) + val resolvedPassword = password ?: settingsDataStore.getSavedServerPassword(server) + val result = manager.connect(server.toServerConfig(), resolvedPassword) state.value = result.fold( onSuccess = { manager.connectionState.value }, onFailure = { ConnectionState.Error(it.message ?: "Connection failed") }, @@ -61,6 +77,7 @@ class ServerConnectionRegistry constructor( } fun disconnect(serverRef: ServerRef) { + stateCollectors.remove(serverRef.endpointKey)?.cancel() managers.remove(serverRef.endpointKey)?.disconnect() connections.remove(serverRef.endpointKey) stateFlow(serverRef.endpointKey).value = ConnectionState.Disconnected diff --git a/app/src/main/java/dev/blazelight/p4oc/data/files/ofish/OfishSessionFactory.kt b/app/src/main/java/dev/blazelight/p4oc/data/files/ofish/OfishSessionFactory.kt index fe4c0f3f..41561a4d 100644 --- a/app/src/main/java/dev/blazelight/p4oc/data/files/ofish/OfishSessionFactory.kt +++ b/app/src/main/java/dev/blazelight/p4oc/data/files/ofish/OfishSessionFactory.kt @@ -40,10 +40,12 @@ internal class OfishSessionFactory( synchronized(activeSessionIds) { activeSessionIds -= session.id } withContext(NonCancellable) { runCatching { client.deleteSession(session.id) } - .onFailure { error -> AppLog.w( - TAG, - "Failed to delete OFISH session ${session.id}: ${error.message}" - ) } + .onFailure { error -> + AppLog.w( + TAG, + "Failed to delete OFISH session ${session.id}: ${error.message}" + ) + } } } } diff --git a/app/src/main/java/dev/blazelight/p4oc/data/session/SessionRepositoryImpl.kt b/app/src/main/java/dev/blazelight/p4oc/data/session/SessionRepositoryImpl.kt index 5017b771..f0222ecc 100644 --- a/app/src/main/java/dev/blazelight/p4oc/data/session/SessionRepositoryImpl.kt +++ b/app/src/main/java/dev/blazelight/p4oc/data/session/SessionRepositoryImpl.kt @@ -25,6 +25,7 @@ import dev.blazelight.p4oc.domain.model.ToolState import dev.blazelight.p4oc.domain.model.isQuestionTool import dev.blazelight.p4oc.domain.session.SessionId import dev.blazelight.p4oc.domain.session.WorkspaceSession +import dev.blazelight.p4oc.domain.workspace.Workspace import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher @@ -559,7 +560,7 @@ class SessionRepositoryImpl( val globalDeferred = async { trackedStep("Loading global sessions") { semaphore.withPermit { - client.listSessions(directory = null, roots = true, limit = 100) + client.listSessions(directory = null, roots = true, limit = SESSION_HISTORY_LIMIT) } } } @@ -570,7 +571,7 @@ class SessionRepositoryImpl( client.listSessions( directory = project.worktree, roots = true, - limit = 100, + limit = SESSION_HISTORY_LIMIT, scope = "project", ) } @@ -660,7 +661,10 @@ class SessionRepositoryImpl( private fun workspaceSession(session: Session): WorkspaceSession = WorkspaceSession( id = SessionId(session.id), - workspace = client.workspace, + workspace = Workspace( + server = client.workspace.server, + directory = session.directory.takeIf { it.isNotBlank() }, + ), session = session, ) @@ -896,6 +900,7 @@ class SessionRepositoryImpl( const val FRESHNESS_MS = 30_000L const val MAX_CONCURRENT = 10 const val SEARCH_LIMIT = 100 + const val SESSION_HISTORY_LIMIT = Int.MAX_VALUE const val TAG = "SessionRepository" const val RESOLVED_QUESTION_TTL_MS = 30_000L } diff --git a/app/src/main/java/dev/blazelight/p4oc/di/KoinModules.kt b/app/src/main/java/dev/blazelight/p4oc/di/KoinModules.kt index e74bbacc..4a9e1a64 100644 --- a/app/src/main/java/dev/blazelight/p4oc/di/KoinModules.kt +++ b/app/src/main/java/dev/blazelight/p4oc/di/KoinModules.kt @@ -6,6 +6,7 @@ import dev.blazelight.p4oc.core.haptic.HapticFeedback import dev.blazelight.p4oc.core.network.ConnectionManager import dev.blazelight.p4oc.core.network.MdnsDiscoveryManager import dev.blazelight.p4oc.core.network.PtyWebSocketClient +import dev.blazelight.p4oc.core.network.ServerConnectionRegistry import dev.blazelight.p4oc.core.notification.NotificationEventObserver import dev.blazelight.p4oc.core.notification.NotificationHelper import dev.blazelight.p4oc.core.security.CredentialStore @@ -16,7 +17,6 @@ import dev.blazelight.p4oc.data.server.ActiveServerApiProvider import dev.blazelight.p4oc.data.server.StaleWorkspaceClientException import dev.blazelight.p4oc.data.session.SessionRepositoryImpl import dev.blazelight.p4oc.data.session.SessionRepositoryProvider -import dev.blazelight.p4oc.domain.server.ServerRef import dev.blazelight.p4oc.ui.screens.chat.ChatViewModel import dev.blazelight.p4oc.ui.screens.chat.ModelSelectionCoordinator import dev.blazelight.p4oc.ui.screens.files.FilesViewModel @@ -77,24 +77,25 @@ val networkModule = module { single { MdnsDiscoveryManager(androidContext()) } factory { PtyWebSocketClient(get()) } single { ConnectionManager(get(), get(), get()) } + single { + ServerConnectionRegistry( + settingsDataStore = get(), + connectionManagerFactory = { ConnectionManager(get(), get(), get()) }, + ) + } single { - val connectionManager: ConnectionManager = get() + val registry: ServerConnectionRegistry = get() ActiveServerApiProvider { serverRef, generation -> - val activeBaseUrl = connectionManager.currentBaseUrl - ?: throw StaleWorkspaceClientException("No active server for workspace ${serverRef.endpointKey} generation=${generation.value}") - val activeServerRef = ServerRef.fromEndpoint(activeBaseUrl) - if (activeServerRef != serverRef) { - throw StaleWorkspaceClientException( - "Workspace server ${serverRef.endpointKey} does not match active server ${activeServerRef.endpointKey}", - ) - } - val activeGeneration = connectionManager.currentGeneration + val activeGeneration = registry.generation(serverRef) if (activeGeneration != generation) { throw StaleWorkspaceClientException( - "Workspace generation ${generation.value} does not match active generation ${activeGeneration?.value ?: ""}", + "Workspace generation ${generation.value} does not match server " + + "${serverRef.endpointKey} generation ${activeGeneration?.value ?: ""}", ) } - connectionManager.requireApi() + registry.api(serverRef) ?: throw StaleWorkspaceClientException( + "No connected API for workspace server ${serverRef.endpointKey} generation=${generation.value}", + ) } } single { SessionRepositoryProvider(get(), get(), get()) } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/FilePickerDialog.kt b/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/FilePickerDialog.kt index 321681ae..40ef9764 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/FilePickerDialog.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/FilePickerDialog.kt @@ -251,9 +251,13 @@ fun FilePickerDialog( fontFamily = FontFamily.Monospace ) Text( - text = if (searchQuery.isNotBlank()) stringResource( - R.string.no_matching_files - ) else stringResource(R.string.empty_folder), + text = if (searchQuery.isNotBlank()) { + stringResource( + R.string.no_matching_files + ) + } else { + stringResource(R.string.empty_folder) + }, color = theme.textMuted, fontFamily = FontFamily.Monospace ) diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/components/command/CommandPalette.kt b/app/src/main/java/dev/blazelight/p4oc/ui/components/command/CommandPalette.kt index 5516e4a2..cb0a83db 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/components/command/CommandPalette.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/components/command/CommandPalette.kt @@ -124,7 +124,7 @@ fun CommandPalette( arguments = commandArgs, onArgumentsChange = { commandArgs = it }, onBack = { - selectedCommand = null; + selectedCommand = null commandArgs = "" }, onExecute = { diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/components/status/ServerStatusVisual.kt b/app/src/main/java/dev/blazelight/p4oc/ui/components/status/ServerStatusVisual.kt new file mode 100644 index 00000000..ab7925f3 --- /dev/null +++ b/app/src/main/java/dev/blazelight/p4oc/ui/components/status/ServerStatusVisual.kt @@ -0,0 +1,88 @@ +package dev.blazelight.p4oc.ui.components.status + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Circle +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import dev.blazelight.p4oc.R +import dev.blazelight.p4oc.ui.screens.server.ServerConnectionStatus +import dev.blazelight.p4oc.ui.theme.LocalOpenCodeTheme +import dev.blazelight.p4oc.ui.theme.Sizing +import dev.blazelight.p4oc.ui.theme.Spacing + +data class ServerStatusVisual( + val color: Color, + val label: String, + val contentDescription: String, + val icon: ImageVector = Icons.Default.Circle, + val showSpinner: Boolean = false, +) + +@Composable +fun serverStatusVisual(status: ServerConnectionStatus): ServerStatusVisual { + val theme = LocalOpenCodeTheme.current + return when (status) { + ServerConnectionStatus.CONNECTED -> ServerStatusVisual( + theme.success, + stringResource(R.string.server_status_connected), + stringResource(R.string.server_status_cd_connected) + ) + ServerConnectionStatus.CONNECTING -> ServerStatusVisual( + theme.accent, + stringResource(R.string.server_status_connecting), + stringResource(R.string.server_status_cd_connecting), + Icons.Default.Refresh, + true + ) + ServerConnectionStatus.AVAILABLE -> ServerStatusVisual( + theme.success, + stringResource(R.string.server_status_nearby), + stringResource(R.string.server_status_cd_nearby) + ) + ServerConnectionStatus.DISCONNECTED -> ServerStatusVisual( + theme.textMuted, + stringResource(R.string.server_status_offline), + stringResource(R.string.server_status_cd_offline) + ) + ServerConnectionStatus.ERROR -> ServerStatusVisual( + theme.error, + stringResource(R.string.server_status_error), + stringResource(R.string.server_status_cd_error), + Icons.Default.Error + ) + } +} + +@Composable +fun serverStatusIndicator(status: ServerConnectionStatus, modifier: Modifier = Modifier) { + val visual = serverStatusVisual(status) + Row( + modifier = modifier.testTag("server_status_${status.name.lowercase()}"), + horizontalArrangement = Arrangement.spacedBy(Spacing.xs), + verticalAlignment = Alignment.CenterVertically, + ) { + if (visual.showSpinner) { + CircularProgressIndicator( + Modifier.size(Sizing.indicatorDotActive), + color = visual.color, + strokeWidth = Sizing.strokeMd + ) + } else { + Icon(visual.icon, visual.contentDescription, Modifier.size(Sizing.indicatorDotActive), tint = visual.color) + } + Text(visual.label, color = visual.color) + } +} diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/components/toolwidgets/ToolGroupWidget.kt b/app/src/main/java/dev/blazelight/p4oc/ui/components/toolwidgets/ToolGroupWidget.kt index 17813411..34c8aeb9 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/components/toolwidgets/ToolGroupWidget.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/components/toolwidgets/ToolGroupWidget.kt @@ -86,7 +86,9 @@ fun ToolGroupWidget( tools.groupBy { it.toolName } .map { (name, toolList) -> val state = when { - toolList.any { it.state is ToolState.Pending || it.callID in pendingPermissionIdsByCallId.keys } -> AggregateToolState.PENDING + toolList.any { + it.state is ToolState.Pending || it.callID in pendingPermissionIdsByCallId.keys + } -> AggregateToolState.PENDING toolList.any { it.state is ToolState.Running } -> AggregateToolState.RUNNING toolList.any { it.state is ToolState.Error } -> AggregateToolState.ERROR else -> AggregateToolState.COMPLETED @@ -102,7 +104,7 @@ fun ToolGroupWidget( AggregateToolState.PENDING -> 1 AggregateToolState.ERROR -> 2 AggregateToolState.COMPLETED -> 3 - } + } }, { it.name } ) @@ -191,7 +193,11 @@ fun ToolGroupWidget( // Show approval buttons for live or recovered pending permissions. if (tool.callID in pendingPermissionIdsByCallId.keys) { PendingApprovalButtonsInline( - onApprove = { onToolApprove(pendingPermissionIdsByCallId[tool.callID] ?: tool.callID) }, + onApprove = { + onToolApprove( + pendingPermissionIdsByCallId[tool.callID] ?: tool.callID + ) + }, onDeny = { onToolDeny(pendingPermissionIdsByCallId[tool.callID] ?: tool.callID) } ) } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/navigation/NavGraph.kt b/app/src/main/java/dev/blazelight/p4oc/ui/navigation/NavGraph.kt index 9cc51da5..2a408d91 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/navigation/NavGraph.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/navigation/NavGraph.kt @@ -6,12 +6,14 @@ import androidx.compose.runtime.Composable import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable -import dev.blazelight.p4oc.ui.screens.server.ServerScreen +import dev.blazelight.p4oc.core.network.ServerConnectionRegistry +import dev.blazelight.p4oc.ui.screens.server.serverScreen import dev.blazelight.p4oc.ui.screens.settings.ProviderConfigScreen import dev.blazelight.p4oc.ui.screens.settings.SettingsScreen import dev.blazelight.p4oc.ui.screens.settings.VisualSettingsScreen import dev.blazelight.p4oc.ui.screens.setup.SetupScreen import dev.blazelight.p4oc.ui.tabs.MainTabScreen +import org.koin.compose.koinInject private const val ANIMATION_DURATION = 300 @@ -64,7 +66,7 @@ fun NavGraph( } composable(Screen.Server.route) { - ServerScreen( + serverScreen( onNavigateToSessions = { navController.navigate(Screen.Sessions.route) { popUpTo(Screen.Server.route) { inclusive = true } @@ -81,13 +83,25 @@ fun NavGraph( ) } + composable(Screen.ServerManagement.route) { + val serverConnectionRegistry: ServerConnectionRegistry = koinInject() + serverScreen( + onNavigateToSessions = { navController.popBackStack() }, + onNavigateToProjects = { navController.popBackStack() }, + onSettings = { navController.navigate(Screen.Settings.route) }, + autoReconnect = false, + onConnectSavedServer = { saved -> + serverConnectionRegistry.connect(saved) + navController.popBackStack() + }, + ) + } + // Main tab container - this is where the tab-based UI lives composable(Screen.Sessions.route) { MainTabScreen( onDisconnect = { - navController.navigate(Screen.Server.route) { - popUpTo(0) { inclusive = true } - } + navController.navigate(Screen.ServerManagement.route) } ) } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/navigation/Screen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/navigation/Screen.kt index 68d2541d..9d3d4d4b 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/navigation/Screen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/navigation/Screen.kt @@ -5,6 +5,7 @@ import android.net.Uri sealed class Screen(val route: String) { data object Setup : Screen("setup") data object Server : Screen("server") + data object ServerManagement : Screen("server/manage") data object Home : Screen("home") data object Sessions : Screen("sessions") diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/preview/ComponentPreviews.kt b/app/src/main/java/dev/blazelight/p4oc/ui/preview/ComponentPreviews.kt index f237b2ce..710fd7d0 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/preview/ComponentPreviews.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/preview/ComponentPreviews.kt @@ -266,7 +266,6 @@ private fun ChatInputBarPreview() { } } - /** * Preview for git status badges */ diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatScreen.kt index 11ac84eb..1e590952 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatScreen.kt @@ -163,7 +163,7 @@ fun ChatScreen( lastVisible != null && lastVisible.index >= lastItemIndex && lastItemBottom <= layoutInfo.viewportEndOffset - ) + ) } } @@ -352,134 +352,136 @@ fun ChatScreen( .fillMaxSize() .weight(1f) ) { - // Revert active banner - uiState.session?.revert?.let { - val theme = LocalOpenCodeTheme.current - Surface( - color = theme.warning.copy(alpha = 0.15f), - shape = RectangleShape - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = Spacing.md, vertical = Spacing.sm), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + // Revert active banner + uiState.session?.revert?.let { + val theme = LocalOpenCodeTheme.current + Surface( + color = theme.warning.copy(alpha = 0.15f), + shape = RectangleShape ) { - Text( - text = "\u21BA ${stringResource(R.string.revert_active_banner)}", - style = MaterialTheme.typography.labelMedium, - color = theme.warning - ) - Text( - text = "[${stringResource(R.string.unrevert_all)}]", - style = MaterialTheme.typography.labelMedium.copy(fontFamily = FontFamily.Monospace), - color = theme.accent, - modifier = Modifier.clickable(role = Role.Button) { viewModel.unrevertSession() } - ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Spacing.md, vertical = Spacing.sm), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "\u21BA ${stringResource(R.string.revert_active_banner)}", + style = MaterialTheme.typography.labelMedium, + color = theme.warning + ) + Text( + text = "[${stringResource(R.string.unrevert_all)}]", + style = MaterialTheme.typography.labelMedium.copy(fontFamily = FontFamily.Monospace), + color = theme.accent, + modifier = Modifier.clickable(role = Role.Button) { viewModel.unrevertSession() } + ) + } } } - } - val hasContent = messages.isNotEmpty() || uiState.isBusy + val hasContent = messages.isNotEmpty() || uiState.isBusy - if (!hasContent && !uiState.isLoading) { - EmptyChatView(modifier = Modifier.align(Alignment.Center)) - } else { - LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize().testTag("message_list"), - contentPadding = PaddingValues(vertical = Spacing.xxs, horizontal = Spacing.xs), - verticalArrangement = Arrangement.spacedBy(Spacing.hairline), - ) { - // All messages - stable keys ensure only changed items recompose - itemsIndexed( - items = messageBlocks, - key = { _, block -> - when (block) { - is MessageBlock.UserBlock -> block.message.message.id - is MessageBlock.AssistantBlock -> block.messages.first().message.id + if (!hasContent && !uiState.isLoading) { + EmptyChatView(modifier = Modifier.align(Alignment.Center)) + } else { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize().testTag("message_list"), + contentPadding = PaddingValues(vertical = Spacing.xxs, horizontal = Spacing.xs), + verticalArrangement = Arrangement.spacedBy(Spacing.hairline), + ) { + // All messages - stable keys ensure only changed items recompose + itemsIndexed( + items = messageBlocks, + key = { _, block -> + when (block) { + is MessageBlock.UserBlock -> block.message.message.id + is MessageBlock.AssistantBlock -> block.messages.first().message.id + } + } + ) { index, block -> + val isCurrentMatch = scrollRestorationState.showSearch && + scrollRestorationState.searchQuery.isNotBlank() && + searchMatches.getOrNull(scrollRestorationState.currentMatchIndex)?.blockIndex == index + val highlight = if (isCurrentMatch) { + Modifier.background(LocalOpenCodeTheme.current.accent.copy(alpha = 0.08f)) + } else { + Modifier + } + Box(modifier = highlight) { + MessageBlockView( + block = block, + onToolApprove = { viewModel.respondToPermission(it, "once") }, + onToolDeny = { viewModel.respondToPermission(it, "reject") }, + onToolAlways = { viewModel.respondToPermission(it, "always") }, + onOpenSubSession = onOpenSubSession, + defaultToolWidgetState = defaultToolWidgetState, + pendingPermissionsByCallId = pendingPermissionsByCallId, + onRevert = { messageId -> showRevertDialog = messageId } + ) } } - ) { index, block -> - val isCurrentMatch = scrollRestorationState.showSearch && - scrollRestorationState.searchQuery.isNotBlank() && - searchMatches.getOrNull(scrollRestorationState.currentMatchIndex)?.blockIndex == index - val highlight = if (isCurrentMatch) { - Modifier.background(LocalOpenCodeTheme.current.accent.copy(alpha = 0.08f)) - } else { - Modifier - } - Box(modifier = highlight) { - MessageBlockView( - block = block, - onToolApprove = { viewModel.respondToPermission(it, "once") }, - onToolDeny = { viewModel.respondToPermission(it, "reject") }, - onToolAlways = { viewModel.respondToPermission(it, "always") }, - onOpenSubSession = onOpenSubSession, - defaultToolWidgetState = defaultToolWidgetState, - pendingPermissionsByCallId = pendingPermissionsByCallId, - onRevert = { messageId -> showRevertDialog = messageId } - ) - } - } - pendingQuestion?.let { questionRequest -> - item(key = "pending_question_${questionRequest.id}") { - InlineQuestionCard( - questionRequestId = questionRequest.id, - questionData = dev.blazelight.p4oc.domain.model.QuestionData(questionRequest.questions), - onDismiss = { viewModel.dismissQuestion(questionRequest.id) }, - onSubmit = { answers -> - viewModel.respondToQuestion(questionRequest.id, answers) - }, - modifier = Modifier.padding(vertical = Spacing.xs) - ) + pendingQuestion?.let { questionRequest -> + item(key = "pending_question_${questionRequest.id}") { + InlineQuestionCard( + questionRequestId = questionRequest.id, + questionData = dev.blazelight.p4oc.domain.model.QuestionData( + questionRequest.questions + ), + onDismiss = { viewModel.dismissQuestion(questionRequest.id) }, + onSubmit = { answers -> + viewModel.respondToQuestion(questionRequest.id, answers) + }, + modifier = Modifier.padding(vertical = Spacing.xs) + ) + } } } } - } - val activeLoadSteps = buildList { - addAll(uiState.loadingSteps) - if (isPickerLoading) add("Loading files") - } - if (uiState.isLoading || activeLoadSteps.isNotEmpty()) { - TuiLoadingScreen( - modifier = Modifier.align(Alignment.Center), - text = activeLoadSteps.ifEmpty { listOf("Loading session") }.joinToString("\n") - ) - } + val activeLoadSteps = buildList { + addAll(uiState.loadingSteps) + if (isPickerLoading) add("Loading files") + } + if (uiState.isLoading || activeLoadSteps.isNotEmpty()) { + TuiLoadingScreen( + modifier = Modifier.align(Alignment.Center), + text = activeLoadSteps.ifEmpty { listOf("Loading session") }.joinToString("\n") + ) + } - uiState.error?.let { error -> - TuiSnackbar( - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(Spacing.md), - action = { - TextButton(onClick = viewModel::clearError, shape = RectangleShape) { - Text(stringResource(R.string.dismiss)) + uiState.error?.let { error -> + TuiSnackbar( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(Spacing.md), + action = { + TextButton(onClick = viewModel::clearError, shape = RectangleShape) { + Text(stringResource(R.string.dismiss)) + } } + ) { + Text(error) } - ) { - Text(error) } - } - // Jump to bottom button - shows when scrolled away from the tail. - JumpToBottomButton( - visible = !isAtBottom, - hasNewContent = scrollRestorationState.hasNewContentWhileAway, - onClick = { - coroutineScope.launch { - scrollRestorationState.onJumpToBottom() - listState.scrollChatToBottom() - } - }, - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(end = Spacing.xl, bottom = Spacing.md) - ) + // Jump to bottom button - shows when scrolled away from the tail. + JumpToBottomButton( + visible = !isAtBottom, + hasNewContent = scrollRestorationState.hasNewContentWhileAway, + onClick = { + coroutineScope.launch { + scrollRestorationState.onJumpToBottom() + listState.scrollChatToBottom() + } + }, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = Spacing.xl, bottom = Spacing.md) + ) } } } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/files/FileExplorerScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/files/FileExplorerScreen.kt index 1209afb3..08d75620 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/files/FileExplorerScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/files/FileExplorerScreen.kt @@ -509,7 +509,13 @@ private fun FileCreateMenu( expanded = false onCreateFile() }, - leadingIcon = { Icon(Icons.AutoMirrored.Filled.NoteAdd, contentDescription = null, tint = theme.textMuted) } + leadingIcon = { + Icon( + Icons.AutoMirrored.Filled.NoteAdd, + contentDescription = null, + tint = theme.textMuted + ) + } ) DropdownMenuItem( text = { Text(stringResource(R.string.files_new_folder), color = theme.text) }, diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/home/HomeScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/home/HomeScreen.kt index 4dc9de08..f89b09e6 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/home/HomeScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/home/HomeScreen.kt @@ -1,53 +1,110 @@ +@file:Suppress("TooManyFunctions") + package dev.blazelight.p4oc.ui.screens.home +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Chat import androidx.compose.material.icons.filled.Folder -import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Terminal -import androidx.compose.material.icons.filled.ViewList import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextOverflow +import dev.blazelight.p4oc.core.network.ConnectionState +import dev.blazelight.p4oc.domain.model.SessionPresence import dev.blazelight.p4oc.domain.server.WorkspaceKey +import dev.blazelight.p4oc.ui.screens.server.ServerConnectionStatus import dev.blazelight.p4oc.ui.tabs.StartWorkSelection import dev.blazelight.p4oc.ui.tabs.StartWorkTarget import dev.blazelight.p4oc.ui.theme.LocalOpenCodeTheme +import dev.blazelight.p4oc.ui.theme.ProjectColors +import dev.blazelight.p4oc.ui.theme.Sizing import dev.blazelight.p4oc.ui.theme.Spacing -import dev.blazelight.p4oc.ui.theme.TuiShapes +import dev.blazelight.p4oc.ui.theme.opencode.OpenCodeTheme +import java.util.concurrent.TimeUnit -private const val HOME_SERVER_CARD_LIMIT = 2 -private const val HOME_WORKSPACE_LIMIT = 4 +private const val RECENT_DAY_LIMIT = 30 +private const val HOME_WORKSPACE_SHORTCUT_LIMIT = 3 +private const val PERSISTENT_SERVER_CARD_LIMIT = 3 +private const val ALL_SERVERS_CARD_WEIGHT = 0.72f data class HomeActions( val onBrowseSessions: (StartWorkTarget) -> Unit, + val onBrowseAllSessions: () -> Unit = {}, val onOpenFiles: (StartWorkTarget) -> Unit, val onOpenTerminal: (StartWorkTarget) -> Unit, val onChooseTarget: () -> Unit, + val onManageServers: () -> Unit = {}, + val onFocusTab: (String) -> Unit = {}, + val onResumeSession: (SessionPreview) -> Unit = {}, val onWorkspaceSelected: (WorkspaceSummary) -> Unit = {}, val onWorkspaceDetailChanged: (StartWorkSelection) -> Unit = {}, + val onStartScopedWork: (StartWorkTarget) -> Unit = {}, +) + +private data class HomeOverviewInput( + val summary: HomeSummaryState, + val filterEndpointKey: String?, + val searchQuery: String, + val onFilter: (String?) -> Unit, + val onSearchQueryChange: (String) -> Unit, + val showAllWorkspaces: Boolean, + val onShowAllWorkspacesChange: (Boolean) -> Unit, + val onWorkspaceClick: (WorkspaceSummary) -> Unit, + val actions: HomeActions, + val listState: LazyListState, ) -private data class WorkspaceDetailActions( +private data class WorkspaceDetailInput( + val workspace: WorkspaceSummary, + val openWork: List, + val sessions: List, + val actions: HomeActions, val onBack: () -> Unit, - val onBrowseSessions: () -> Unit, - val onOpenFiles: () -> Unit, - val onOpenTerminal: () -> Unit, +) + +private data class ServerFilterHeaderState( + val active: ServerSummary?, + val totalCount: Int, + val allSelected: Boolean, + val expandable: Boolean, + val expanded: Boolean, ) @Composable @@ -57,142 +114,367 @@ fun homeScreen( modifier: Modifier = Modifier, ) { var selectedWorkspace by remember { mutableStateOf(null) } + var filterEndpointKey by remember { mutableStateOf(null) } + var searchQuery by rememberSaveable { mutableStateOf("") } + var showAllWorkspaces by rememberSaveable { mutableStateOf(false) } val selected = selectedWorkspace - if (selected != null) { + if (selected == null) { + homeOverview( + input = HomeOverviewInput( + summary = summary, + filterEndpointKey = filterEndpointKey, + searchQuery = searchQuery, + onFilter = { filterEndpointKey = it }, + onSearchQueryChange = { searchQuery = it }, + showAllWorkspaces = showAllWorkspaces, + onShowAllWorkspacesChange = { showAllWorkspaces = it }, + onWorkspaceClick = { + selectedWorkspace = it + actions.onWorkspaceSelected(it) + actions.onWorkspaceDetailChanged( + StartWorkSelection.Selected(StartWorkTarget(it.serverRef, it.workspaceKey)), + ) + }, + actions = actions, + listState = rememberLazyListState(), + ), + modifier = modifier, + ) + } else { workspaceDetail( - workspace = selected, - openWork = summary.openWork.filter { - it.serverRef == selected.serverRef && it.workspaceKey == selected.workspaceKey - }, - actions = WorkspaceDetailActions( + input = WorkspaceDetailInput( + workspace = selected, + openWork = summary.openWork.filter { + it.serverRef.endpointKey == selected.serverRef.endpointKey && + it.workspaceKey == selected.workspaceKey + }, + sessions = summary.sessions.filter { + it.serverRef.endpointKey == selected.serverRef.endpointKey && + it.workspaceKey == selected.workspaceKey + }, + actions = actions, onBack = { selectedWorkspace = null actions.onWorkspaceDetailChanged(StartWorkSelection.NeedsSelection) }, - onBrowseSessions = { actions.onBrowseSessions(selected.toStartWorkTarget()) }, - onOpenFiles = { actions.onOpenFiles(selected.toStartWorkTarget()) }, - onOpenTerminal = { actions.onOpenTerminal(selected.toStartWorkTarget()) }, ), modifier = modifier, ) - return } - homeOverview( - summary = summary, - onWorkspaceClick = { workspace -> - selectedWorkspace = workspace - actions.onWorkspaceSelected(workspace) - actions.onWorkspaceDetailChanged( - StartWorkSelection.Selected(workspace.toStartWorkTarget()), - ) - }, - onChooseTarget = actions.onChooseTarget, - modifier = modifier, - ) } @Composable -private fun homeOverview( - summary: HomeSummaryState, - onWorkspaceClick: (WorkspaceSummary) -> Unit, - onChooseTarget: () -> Unit, - modifier: Modifier, +private fun homeOverview(input: HomeOverviewInput, modifier: Modifier) { + val summary = input.summary + val results by remember(summary, input.filterEndpointKey, input.searchQuery) { + derivedStateOf { summary.filteredHomeResults(input.filterEndpointKey, input.searchQuery) } + } + LazyColumn( + state = input.listState, + modifier = modifier.fillMaxSize().testTag("home_screen"), + contentPadding = androidx.compose.foundation.layout.PaddingValues(Spacing.md), + verticalArrangement = Arrangement.spacedBy(Spacing.xs), + ) { + homeOverviewContent(input, results) + } +} + +private fun LazyListScope.homeOverviewContent( + input: HomeOverviewInput, + results: FilteredHomeResults, ) { + val summary = input.summary + item { homeHeader(summary, input.actions.onManageServers) } + item { homeSearchField(input.searchQuery, input.onSearchQueryChange) } + item { serverFilters(summary.servers, input.filterEndpointKey, input.onFilter) } + if (input.searchQuery.isNotBlank() && input.filterEndpointKey != null) { + item { + Text( + "Search results include every server · clear search to return to the selected server", + style = MaterialTheme.typography.labelSmall, + color = LocalOpenCodeTheme.current.textMuted, + maxLines = 2, + ) + } + } + if (summary.isLoading) { + item { + infoCard("Loading existing work", "Sessions already loaded remain available while Home refreshes.") + } + } + if (summary.partialFailures.isNotEmpty()) { + item { infoCard("Some work is unavailable", summary.partialFailures.joinToString(" · ")) } + } + homeWorkspaces(input, results.workspaces) + HomeSessions(input, results.sessions) +} + +@Composable +private fun homeSearchField(query: String, onQueryChange: (String) -> Unit) { val theme = LocalOpenCodeTheme.current - Column( - modifier = modifier - .fillMaxSize() - .padding(Spacing.md) - .testTag("home_screen"), - verticalArrangement = Arrangement.spacedBy(Spacing.md), - ) { - homeHeader(summary.servers.size, summary.openWork.size) - serverOverview(summary.servers) - workspaceOverview(summary.workspaces, onWorkspaceClick) - sectionLabel("Browse") - HomeActionRow( - label = "Sessions", - description = "Find previous chats and workspace history.", - icon = { Icon(Icons.Default.ViewList, contentDescription = null, tint = theme.textMuted) }, - onClick = onChooseTarget, - testTag = "home_browse_sessions", - ) - browseActions(onChooseTarget) + BasicTextField( + value = query, + onValueChange = onQueryChange, + singleLine = true, + textStyle = MaterialTheme.typography.bodyMedium.copy(color = theme.text), + cursorBrush = androidx.compose.ui.graphics.SolidColor(theme.accent), + modifier = Modifier + .fillMaxWidth() + .height(Sizing.buttonHeightSm) + .border(Sizing.strokeThin, theme.border, RectangleShape) + .testTag("home_search_field"), + decorationBox = { field -> + Row( + Modifier.fillMaxSize().padding(horizontal = Spacing.sm), + verticalAlignment = Alignment.CenterVertically, + ) { + if (query.isEmpty()) { + Text( + "/ Search every server, session, or workspace…", + style = MaterialTheme.typography.labelMedium, + color = theme.textMuted, + maxLines = 1, + ) + } + field() + } + }, + ) +} + +private val HomeSessions: LazyListScope.(HomeOverviewInput, List) -> Unit = { input, sessions -> + item { sectionLabel("Newest sessions · all ${sessions.size}") } + if (sessions.isEmpty()) { + item { + infoCard( + if (input.searchQuery.isNotBlank()) "No matching sessions" else "No sessions here", + if (input.searchQuery.isNotBlank()) { + "Try another search or server filter." + } else { + "Sessions with history will appear here for quick resume." + }, + ) + } + } else { + items( + items = sessions, + key = { "${it.serverRef.endpointKey}:${it.sessionId.value}" }, + ) { session -> + sessionRow( + session = session, + onResume = { input.actions.onResumeSession(session) }, + onWorkspace = { + input.onWorkspaceClick( + WorkspaceSummary( + serverRef = session.serverRef, + workspaceKey = session.workspaceKey, + sessionCount = 0, + openTabCount = 0, + mostRecentAt = session.updatedAt, + ), + ) + }, + ) + } + } +} + +@Suppress("LongMethod") +private fun LazyListScope.homeWorkspaces(input: HomeOverviewInput, filteredWorkspaces: List) { + item { sectionLabel(if (input.showAllWorkspaces) "All workspaces" else "Recent workspaces") } + if (filteredWorkspaces.isEmpty()) { + item { + infoCard( + if (input.summary.isLoading) { + "Looking for workspaces" + } else if (input.searchQuery.isNotBlank()) { + "No matching workspaces" + } else { + "No resumable work" + }, + if (input.searchQuery.isNotBlank()) { + "Try another search or server filter." + } else { + "Choose another server filter, or use + to start something new." + }, + ) + } + } else if (input.showAllWorkspaces || input.searchQuery.isNotBlank()) { + items(filteredWorkspaces, key = { "${it.serverRef.endpointKey}:${it.workspaceKey}" }) { workspace -> + workspaceShortcut(workspace, input.onWorkspaceClick, Modifier.fillMaxWidth()) + } + if (input.searchQuery.isBlank()) { + item { + textAction( + "Show recent only", + "Collapse the workspace list", + { input.onShowAllWorkspacesChange(false) }, + ) + } + } + } else { + item { + Column( + Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(Spacing.xxs), + ) { + filteredWorkspaces.take(HOME_WORKSPACE_SHORTCUT_LIMIT).forEach { workspace -> + workspaceShortcut(workspace, input.onWorkspaceClick, Modifier.fillMaxWidth()) + } + } + } + if (filteredWorkspaces.size > HOME_WORKSPACE_SHORTCUT_LIMIT) { + item { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "${HOME_WORKSPACE_SHORTCUT_LIMIT.coerceAtMost(filteredWorkspaces.size)} most recently used", + style = MaterialTheme.typography.labelSmall, + color = LocalOpenCodeTheme.current.textMuted, + ) + textAction( + "All ${filteredWorkspaces.size} workspaces ›", + "", + { input.onShowAllWorkspacesChange(true) }, + Modifier.testTag("home_all_workspaces_action"), + ) + } + } + } } } @Composable -private fun serverOverview(servers: List) { - if (servers.isEmpty()) return - sectionLabel("Servers") +private fun homeHeader(summary: HomeSummaryState, onServers: () -> Unit) { Row( - horizontalArrangement = Arrangement.spacedBy(Spacing.sm), modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - servers.take(HOME_SERVER_CARD_LIMIT).forEach { server -> - serverCard(server, Modifier.weight(1f)) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(Spacing.xxs)) { + Text("[ Home ]", style = MaterialTheme.typography.titleMedium, color = LocalOpenCodeTheme.current.text) + Text( + "${summary.sessions.size} sessions · ${summary.workspaces.size} workspaces", + style = MaterialTheme.typography.labelSmall, + color = LocalOpenCodeTheme.current.textMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } + textAction("Servers ›", "", onServers, Modifier.testTag("home_servers_action")) } } @Composable -private fun workspaceOverview( - workspaces: List, - onWorkspaceClick: (WorkspaceSummary) -> Unit, +private fun serverFilters( + servers: List, + selected: String?, + onSelect: (String?) -> Unit, ) { - sectionLabel("Resume") - if (workspaces.isEmpty()) { - emptyHomeCard() - } else { - workspaces.take(HOME_WORKSPACE_LIMIT).forEach { workspace -> - workspaceRow(workspace) { onWorkspaceClick(workspace) } + var expanded by rememberSaveable { mutableStateOf(false) } + val active = servers.firstOrNull { it.serverRef.endpointKey == selected } + val useCompactSelector = servers.size > PERSISTENT_SERVER_CARD_LIMIT + Column( + Modifier.fillMaxWidth().testTag("home_server_filters"), + verticalArrangement = Arrangement.spacedBy(Spacing.xxs), + ) { + if (useCompactSelector) { + serverFilterHeader( + ServerFilterHeaderState( + active = active, + totalCount = servers.sumOf { it.sessionCount }, + allSelected = selected == null, + expandable = true, + expanded = expanded, + ), + ) { expanded = !expanded } + if (expanded) { + expandedServerSelector(servers, selected) { endpointKey -> + onSelect(endpointKey) + expanded = false + } + } + } else { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(Spacing.xxs)) { + allServersRailCard( + count = servers.sumOf { it.sessionCount }, + selected = selected == null, + modifier = Modifier.weight(ALL_SERVERS_CARD_WEIGHT), + ) { onSelect(null) } + servers.forEach { server -> + serverRailCard( + server = server, + selected = selected == server.serverRef.endpointKey, + modifier = Modifier.weight(1f), + ) { onSelect(server.serverRef.endpointKey) } + } + } } } } @Composable -private fun browseActions(onChooseTarget: () -> Unit) { +private fun allServersRailCard(count: Int, selected: Boolean, modifier: Modifier, onClick: () -> Unit) { val theme = LocalOpenCodeTheme.current - Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { - HomeActionRow( - label = "Files", - description = "Open file browser.", - icon = { Icon(Icons.Default.Folder, contentDescription = null, tint = theme.textMuted) }, - onClick = onChooseTarget, - testTag = "home_open_files", - modifier = Modifier.weight(1f), - ) - HomeActionRow( - label = "Terminal", - description = "Open shell.", - icon = { Icon(Icons.Default.Terminal, contentDescription = null, tint = theme.textMuted) }, - onClick = onChooseTarget, - testTag = "home_open_terminal", - modifier = Modifier.weight(1f), - ) + Surface( + onClick = onClick, + shape = RectangleShape, + color = if (selected) theme.backgroundElement else theme.backgroundPanel, + modifier = modifier.then( + if (selected) Modifier.border(Sizing.strokeMd, theme.accent, RectangleShape) else Modifier, + ), + ) { + Column(Modifier.fillMaxWidth().padding(horizontal = Spacing.sm, vertical = Spacing.xs)) { + Text( + "All", + style = MaterialTheme.typography.labelMedium, + color = theme.text, + maxLines = 1, + ) + Text( + "$count sessions", + style = MaterialTheme.typography.labelSmall, + color = theme.textMuted, + maxLines = 1, + ) + } } } @Composable -private fun homeHeader(serverCount: Int, openWorkCount: Int) { +private fun serverFilterHeader( + state: ServerFilterHeaderState, + onClick: () -> Unit, +) { val theme = LocalOpenCodeTheme.current Surface( - modifier = Modifier.fillMaxWidth(), + onClick = onClick, shape = RectangleShape, - color = theme.backgroundElement, + color = if (state.allSelected || state.active != null) theme.backgroundElement else theme.backgroundPanel, ) { - Row( - modifier = Modifier.padding(Spacing.md), - horizontalArrangement = Arrangement.spacedBy(Spacing.md), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon(Icons.Default.Home, contentDescription = null, tint = theme.accent) - Column(verticalArrangement = Arrangement.spacedBy(Spacing.xxs)) { - Text("Home", style = MaterialTheme.typography.titleMedium, color = theme.text) + Row(Modifier.fillMaxWidth().padding(horizontal = Spacing.sm, vertical = Spacing.xs)) { + Text( + state.active?.displayName ?: "All servers", + style = MaterialTheme.typography.labelMedium, + color = theme.text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Text( + "${state.active?.sessionCount ?: state.totalCount}", + style = MaterialTheme.typography.labelSmall, + color = theme.textMuted, + ) + if (state.expandable) { Text( - "$openWorkCount open · $serverCount server${if (serverCount == 1) "" else "s"}", - style = MaterialTheme.typography.bodySmall, - color = theme.textMuted, + if (state.expanded) "▴" else "▾", + style = MaterialTheme.typography.labelSmall, + color = theme.accent, + modifier = Modifier.padding(start = Spacing.xs), ) } } @@ -200,187 +482,501 @@ private fun homeHeader(serverCount: Int, openWorkCount: Int) { } @Composable -private fun sectionLabel(text: String) { - val theme = LocalOpenCodeTheme.current - Text( - text = text.uppercase(), - style = MaterialTheme.typography.labelMedium, - color = theme.textMuted, - ) +private fun expandedServerSelector( + servers: List, + selected: String?, + onSelect: (String?) -> Unit, +) { + serverSelectorItem("All servers", servers.sumOf { it.sessionCount }, selected == null) { + onSelect(null) + } + servers.forEach { server -> + serverSelectorItem( + label = server.displayName, + count = server.sessionCount, + selected = selected == server.serverRef.endpointKey, + status = server.connectionState.toServerStatus(), + ) { onSelect(server.serverRef.endpointKey) } + } } @Composable -private fun serverCard(server: ServerSummary, modifier: Modifier = Modifier) { +private fun serverSelectorItem( + label: String, + count: Int, + selected: Boolean, + status: ServerConnectionStatus? = null, + onClick: () -> Unit, +) { val theme = LocalOpenCodeTheme.current Surface( - modifier = modifier, + onClick = onClick, shape = RectangleShape, - color = theme.backgroundElement, + color = if (selected) theme.backgroundElement else theme.backgroundPanel, + modifier = if (selected) Modifier.border(Sizing.strokeMd, theme.accent, RectangleShape) else Modifier, ) { - Column( - modifier = Modifier.padding(Spacing.sm), - verticalArrangement = Arrangement.spacedBy(Spacing.xxs), + Row( + Modifier.fillMaxWidth().padding(horizontal = Spacing.sm, vertical = Spacing.xs), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.xs), ) { + if (status != null) { + Box(Modifier.size(Sizing.indicatorDot).background(status.dotColor(theme), CircleShape)) + } Text( - "${server.serverRef.badgeLabel} ${server.displayName}", + label, style = MaterialTheme.typography.labelMedium, color = theme.text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), ) - Text( - "${server.openTabCount} open", - style = MaterialTheme.typography.bodySmall, - color = theme.textMuted, - ) + Text("$count", style = MaterialTheme.typography.labelSmall, color = theme.textMuted) } } } @Composable -private fun workspaceRow(workspace: WorkspaceSummary, onClick: () -> Unit) { - HomeActionRow( - label = workspace.workspaceKey.displayLabel(), - description = "${workspace.serverRef.badgeLabel} ${workspace.serverRef.displayName} · " + - "${workspace.openTabCount} open", - icon = { - Icon( - Icons.Default.Folder, - contentDescription = null, - tint = LocalOpenCodeTheme.current.textMuted, - ) - }, +private fun serverRailCard( + server: ServerSummary, + selected: Boolean, + modifier: Modifier, + onClick: () -> Unit, +) { + val theme = LocalOpenCodeTheme.current + Surface( onClick = onClick, - testTag = "home_workspace_${workspace.serverRef.endpointKey}_${workspace.workspaceKey.displayLabel()}", - ) + shape = RectangleShape, + color = if (selected) theme.backgroundElement else theme.backgroundPanel, + modifier = modifier.then( + if (selected) Modifier.border(Sizing.strokeMd, theme.accent, RectangleShape) else Modifier, + ), + ) { + Row( + Modifier.fillMaxWidth().padding(horizontal = Spacing.sm, vertical = Spacing.xs), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + Modifier + .width(Sizing.strokeThick) + .height(Sizing.buttonHeightMd) + .background(ProjectColors.colorForProject("server:${server.serverRef.endpointKey}")), + ) + Spacer(Modifier.width(Spacing.xs)) + serverRailCardContent(server, theme, Modifier.weight(1f)) + } + } } @Composable -private fun emptyHomeCard() { - HomeSection( - title = "No open work", - body = "Browse sessions to resume work, or use + to start something new.", - ) +private fun serverRailCardContent(server: ServerSummary, theme: OpenCodeTheme, modifier: Modifier = Modifier) { + Column(modifier) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.xs)) { + Box( + Modifier.size(Sizing.indicatorDot) + .background(server.connectionState.toServerStatus().dotColor(theme), CircleShape), + ) + Text( + server.displayName, + style = MaterialTheme.typography.labelMedium, + color = theme.text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + val endpointDetail = serverEndpointDetail(server) + if (endpointDetail != server.displayName) { + Text( + endpointDetail, + style = MaterialTheme.typography.labelSmall, + color = theme.textMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Text( + "${server.sessionCount} sessions", + style = MaterialTheme.typography.labelSmall, + color = theme.textMuted, + maxLines = 1, + ) + } +} + +private fun serverEndpointDetail(server: ServerSummary): String = + server.serverRef.endpointKey.removePrefix("http://").removePrefix("https://") + +private fun ConnectionState.toServerStatus(): ServerConnectionStatus = when (this) { + ConnectionState.Connected -> ServerConnectionStatus.CONNECTED + ConnectionState.Connecting -> ServerConnectionStatus.CONNECTING + ConnectionState.Disconnected -> ServerConnectionStatus.DISCONNECTED + is ConnectionState.Error -> ServerConnectionStatus.ERROR } @Composable -private fun HomeSection(title: String, body: String) { +private fun workspaceShortcut( + workspace: WorkspaceSummary, + onOpen: (WorkspaceSummary) -> Unit, + modifier: Modifier, +) { val theme = LocalOpenCodeTheme.current Surface( - modifier = Modifier.fillMaxWidth(), - shape = TuiShapes.medium, - color = theme.backgroundElement, + onClick = { onOpen(workspace) }, + shape = RectangleShape, + color = theme.backgroundPanel, + modifier = modifier.border(Sizing.strokeThin, theme.border, RectangleShape), ) { - Column( - modifier = Modifier.padding(Spacing.sm), - verticalArrangement = Arrangement.spacedBy(Spacing.xxs), + Row( + Modifier.height(Sizing.listItemHeightSm), + verticalAlignment = Alignment.CenterVertically, ) { - Text(title, style = MaterialTheme.typography.labelMedium, color = theme.text) - Text(body, style = MaterialTheme.typography.bodySmall, color = theme.textMuted) + Box( + Modifier + .width(Sizing.strokeThick) + .height(Sizing.listItemHeightSm) + .background(ProjectColors.colorForProject("server:${workspace.serverRef.endpointKey}")), + ) + Column( + Modifier.weight(1f).padding(horizontal = Spacing.xs), + verticalArrangement = Arrangement.spacedBy(Spacing.xxs), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(Spacing.xxs)) { + Text( + workspace.workspaceKey.displayLabel(), + style = MaterialTheme.typography.labelMedium, + color = theme.text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } + Text( + workspace.workspaceKey.detailLabel(), + style = MaterialTheme.typography.labelSmall, + color = theme.textMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Text( + "${workspace.sessionCount} ›", + style = MaterialTheme.typography.labelSmall, + color = theme.textMuted, + maxLines = 1, + modifier = Modifier.padding(end = Spacing.xxs), + ) } } } @Composable -private fun HomeActionRow( - label: String, - description: String, - icon: @Composable () -> Unit, - onClick: () -> Unit, - testTag: String, - modifier: Modifier = Modifier, +@Suppress("LongMethod") +private fun sessionRow( + session: SessionPreview, + onResume: () -> Unit, + onWorkspace: (() -> Unit)? = null, ) { val theme = LocalOpenCodeTheme.current Surface( - onClick = onClick, - modifier = modifier.fillMaxWidth().testTag(testTag), + onClick = onResume, shape = RectangleShape, - color = theme.background, + color = theme.backgroundElement, + modifier = Modifier.fillMaxWidth(), ) { Row( - modifier = Modifier.padding(Spacing.sm), + Modifier.padding(horizontal = Spacing.sm, vertical = Spacing.xs), horizontalArrangement = Arrangement.spacedBy(Spacing.sm), verticalAlignment = Alignment.CenterVertically, ) { - icon() - Column(verticalArrangement = Arrangement.spacedBy(Spacing.xxs)) { - Text(label, style = MaterialTheme.typography.labelMedium, color = theme.text) - Text(description, style = MaterialTheme.typography.bodySmall, color = theme.textMuted) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(Spacing.xxs)) { + Text( + session.title, + style = MaterialTheme.typography.bodyMedium, + color = theme.text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(Spacing.sm), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "● ${session.status.label()}", + style = MaterialTheme.typography.labelSmall, + color = session.status.statusColor(theme), + ) + Text( + recency(session.updatedAt), + style = MaterialTheme.typography.labelSmall, + color = theme.textMuted, + ) + if (session.childCount > 0) { + Text( + "[${session.childCount} sub]", + style = MaterialTheme.typography.labelSmall, + color = theme.info, + ) + } + if (session.additions > 0) { + Text( + "+${session.additions}", + style = MaterialTheme.typography.labelSmall, + color = theme.success, + ) + } + if (session.deletions > 0) { + Text("-${session.deletions}", style = MaterialTheme.typography.labelSmall, color = theme.error) + } + if (session.isShared) { + Text("◈ Shared", style = MaterialTheme.typography.labelSmall, color = theme.info) + } + } } + SessionWorkspaceLabel(session, onWorkspace) } } } +private val SessionWorkspaceLabel: @Composable (SessionPreview, (() -> Unit)?) -> Unit = { session, onWorkspace -> + val projectKey = "${session.serverRef.endpointKey}:${session.workspaceKey}" + Surface( + shape = RectangleShape, + color = ProjectColors.colorForProject(projectKey), + modifier = Modifier + .widthIn(max = Sizing.chipMaxWidth) + .then( + if (onWorkspace == null) Modifier else Modifier.clickable(role = Role.Button, onClick = onWorkspace), + ), + ) { + Text( + session.workspaceKey.displayLabel(), + style = MaterialTheme.typography.labelSmall, + color = ProjectColors.textColorForProject(projectKey), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = Spacing.sm, vertical = Spacing.xs), + ) + } +} + +private fun SessionPresence.statusColor( + theme: dev.blazelight.p4oc.ui.theme.opencode.OpenCodeTheme, +) = when (this) { + SessionPresence.ERROR, SessionPresence.RETRYING -> theme.error + SessionPresence.AWAITING_INPUT -> theme.warning + SessionPresence.BUSY, SessionPresence.UNREAD -> theme.success + SessionPresence.IDLE, SessionPresence.BACKGROUND -> theme.textMuted +} + +private fun ServerConnectionStatus.dotColor( + theme: OpenCodeTheme, +): Color = when (this) { + ServerConnectionStatus.CONNECTED -> theme.success + ServerConnectionStatus.CONNECTING -> theme.accent + ServerConnectionStatus.AVAILABLE -> theme.success + ServerConnectionStatus.DISCONNECTED -> theme.textMuted + ServerConnectionStatus.ERROR -> theme.error +} + @Composable private fun workspaceDetail( - workspace: WorkspaceSummary, - openWork: List, - actions: WorkspaceDetailActions, - modifier: Modifier = Modifier, + input: WorkspaceDetailInput, + modifier: Modifier, ) { - val theme = LocalOpenCodeTheme.current - Column( - modifier = modifier - .fillMaxSize() - .padding(Spacing.md) - .testTag("home_workspace_detail"), + val workspace = input.workspace + val target = StartWorkTarget(workspace.serverRef, workspace.workspaceKey) + LazyColumn( + modifier = modifier.fillMaxSize().testTag("home_workspace_detail"), + contentPadding = androidx.compose.foundation.layout.PaddingValues(Spacing.md), verticalArrangement = Arrangement.spacedBy(Spacing.md), ) { - HomeActionRow( - label = "← Home", - description = "Back to all workspaces", - icon = { Icon(Icons.Default.Home, contentDescription = null, tint = theme.textMuted) }, - onClick = actions.onBack, - testTag = "home_workspace_detail_back", - ) - HomeSection( - title = workspace.workspaceKey.displayLabel(), - body = "${workspace.serverRef.badgeLabel} ${workspace.serverRef.displayName} · " + - workspace.workspaceKey.detailLabel(), - ) - HomeSection( - title = "Open in this workspace", - body = openWork.joinToString { it.route }.ifBlank { "No open work in this workspace yet." }, - ) - HomeActionRow( - label = "Browse filtered sessions", - description = "Use Sessions search/actions scoped to this workspace.", - icon = { Icon(Icons.Default.ViewList, contentDescription = null, tint = theme.textMuted) }, - onClick = actions.onBrowseSessions, - testTag = "home_workspace_detail_sessions", - ) - Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { - HomeActionRow( - label = "+ Files", - description = "Focus or create Files for this workspace.", - icon = { Icon(Icons.Default.Folder, contentDescription = null, tint = theme.textMuted) }, - onClick = actions.onOpenFiles, - testTag = "home_workspace_detail_files", - modifier = Modifier.weight(1f), + item { + textAction( + label = "← Home", + description = "Back to the previous Home filter and position", + onClick = input.onBack, + modifier = Modifier.testTag("home_workspace_detail_back"), ) - HomeActionRow( - label = "+ Terminal", - description = "Create Terminal here.", - icon = { Icon(Icons.Default.Terminal, contentDescription = null, tint = theme.textMuted) }, - onClick = actions.onOpenTerminal, - testTag = "home_workspace_detail_terminal", - modifier = Modifier.weight(1f), + } + item { + infoCard( + workspace.workspaceKey.displayLabel(), + "${workspace.workspaceKey.detailLabel()}\n" + + "${workspace.serverRef.badgeLabel} ${workspace.serverRef.displayName}", ) } + item { sectionLabel("Open work") } + workspaceOpenWork(input) + item { sectionLabel("Sessions in this workspace") } + workspaceSessions(input) + item { sectionLabel("Start new work") } + item { + textAction( + label = "New chat, Files, or Terminal", + description = "Create work through the shared coordinator in this exact workspace", + onClick = { input.actions.onStartScopedWork(target) }, + ) + } + } +} + +private fun androidx.compose.foundation.lazy.LazyListScope.workspaceOpenWork(input: WorkspaceDetailInput) { + if (input.openWork.isEmpty()) { + item { + infoCard("Nothing open", "Existing tabs for this exact workspace appear here.") + } + } else { + items(input.openWork, key = { it.tabId }) { work -> + openWorkCard(work) { input.actions.onFocusTab(work.tabId) } + } + } +} + +private fun androidx.compose.foundation.lazy.LazyListScope.workspaceSessions(input: WorkspaceDetailInput) { + if (input.sessions.isEmpty()) { + item { + infoCard( + "No sessions", + "No resumable sessions were found for this exact server and workspace.", + ) + } + } else { + items(input.sessions, key = { it.sessionId.value }) { session -> + sessionRow(session = session, onResume = { input.actions.onResumeSession(session) }) + } + } +} + +@Composable +private fun openWorkCard(work: OpenWorkSummary, onFocus: () -> Unit) { + val icon = when (work.type) { + OpenWorkType.Chat -> Icons.Default.Chat + OpenWorkType.Files -> Icons.Default.Folder + OpenWorkType.Terminal -> Icons.Default.Terminal + } + val status = work.status?.label() ?: "Open" + val theme = LocalOpenCodeTheme.current + Surface(shape = RectangleShape, color = theme.backgroundElement, modifier = Modifier.fillMaxWidth()) { + Row( + Modifier.padding(Spacing.sm), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.sm) + ) { + Icon(icon, contentDescription = work.type.name, tint = theme.textMuted) + Column(Modifier.weight(1f)) { + Text(work.title, style = MaterialTheme.typography.labelMedium, color = theme.text) + Text( + "${work.type.name} · $status", + style = MaterialTheme.typography.bodySmall, + color = theme.textMuted, + ) + } + compactAction("Focus", onFocus) + } + } +} + +@Composable +private fun infoCard(title: String, body: String) { + val theme = LocalOpenCodeTheme.current + Surface(shape = RectangleShape, color = theme.backgroundElement, modifier = Modifier.fillMaxWidth()) { + Column(Modifier.padding(Spacing.sm), verticalArrangement = Arrangement.spacedBy(Spacing.xxs)) { + Text(title, style = MaterialTheme.typography.labelMedium, color = theme.text) + Text(body, style = MaterialTheme.typography.bodySmall, color = theme.textMuted) + } } } -private fun WorkspaceSummary.toStartWorkTarget(): StartWorkTarget = StartWorkTarget( - serverRef = serverRef, - workspaceKey = workspaceKey, +@Composable +private fun textAction(label: String, description: String, onClick: () -> Unit, modifier: Modifier = Modifier) { + val theme = LocalOpenCodeTheme.current + Surface(onClick = onClick, shape = RectangleShape, color = theme.background, modifier = modifier) { + Column(Modifier.padding(Spacing.xs)) { + Text(label, style = MaterialTheme.typography.labelMedium, color = theme.accent) + if (description.isNotEmpty()) { + Text(description, style = MaterialTheme.typography.labelSmall, color = theme.textMuted) + } + } + } +} + +@Composable +private fun compactAction(label: String, onClick: () -> Unit) { + Surface(onClick = onClick, shape = RectangleShape, color = LocalOpenCodeTheme.current.background) { + Text( + label, + modifier = Modifier.padding(Spacing.xs), + style = MaterialTheme.typography.labelMedium, + color = LocalOpenCodeTheme.current.accent + ) + } +} + +@Composable +private fun sectionLabel(text: String) = Text( + text.uppercase(), + style = MaterialTheme.typography.labelMedium, + color = LocalOpenCodeTheme.current.textMuted, +) + +internal data class FilteredHomeResults( + val workspaces: List, + val sessions: List, ) +/** Applies browse scope when idle; a query searches every saved server. */ +internal fun HomeSummaryState.filteredHomeResults(endpointKey: String?, query: String): FilteredHomeResults { + val needle = query.trim() + val browseEndpointKey = endpointKey.takeIf { needle.isEmpty() } + val matchingWorkspaces = ArrayList(workspaces.size) + for (workspace in workspaces) { + if (browseEndpointKey != null && workspace.serverRef.endpointKey != browseEndpointKey) continue + if (needle.isEmpty() || workspace.matchesSearch(needle)) matchingWorkspaces += workspace + } + val matchingSessions = ArrayList(sessions.size) + for (session in sessions) { + if (browseEndpointKey != null && session.serverRef.endpointKey != browseEndpointKey) continue + if (needle.isEmpty() || session.matchesSearch(needle)) matchingSessions += session + } + matchingSessions.sortByDescending { it.updatedAt } + return FilteredHomeResults(matchingWorkspaces, matchingSessions) +} + +private fun WorkspaceSummary.matchesSearch(query: String): Boolean = + serverRef.displayName.contains(query, ignoreCase = true) || + workspaceKey.displayLabel().contains(query, ignoreCase = true) || + workspaceKey.detailLabel().contains(query, ignoreCase = true) + +private fun SessionPreview.matchesSearch(query: String): Boolean = + title.contains(query, ignoreCase = true) || + directory.contains(query, ignoreCase = true) || + serverRef.displayName.contains(query, ignoreCase = true) || + workspaceKey.displayLabel().contains(query, ignoreCase = true) || + workspaceKey.detailLabel().contains(query, ignoreCase = true) + private fun WorkspaceKey.displayLabel(): String = when (this) { - WorkspaceKey.Global -> "Global workspace" + WorkspaceKey.Global -> "No project context" is WorkspaceKey.Directory -> value.trimEnd('/').substringAfterLast('/').ifBlank { value } is WorkspaceKey.SessionScoped -> "Session ${sessionId.value}" } - private fun WorkspaceKey.detailLabel(): String = when (this) { WorkspaceKey.Global -> "No project context" is WorkspaceKey.Directory -> value is WorkspaceKey.SessionScoped -> "Session-scoped workspace" } + +private fun SessionPresence.label() = name.lowercase().replaceFirstChar { it.uppercase() } +private fun recency(timestamp: Long, now: Long = System.currentTimeMillis()): String { + if (timestamp <= 0) return "No recent session" + val elapsed = (now - timestamp).coerceAtLeast(0) + val minutes = TimeUnit.MILLISECONDS.toMinutes(elapsed) + val hours = TimeUnit.MILLISECONDS.toHours(elapsed) + val days = TimeUnit.MILLISECONDS.toDays(elapsed) + return when { + minutes < 1 -> "Just now" + hours < 1 -> "${minutes}m ago" + days < 1 -> "${hours}h ago" + days < RECENT_DAY_LIMIT -> "${days}d ago" + else -> "Older" + } +} diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/home/HomeSummary.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/home/HomeSummary.kt index a24e2cff..016874a9 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/home/HomeSummary.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/home/HomeSummary.kt @@ -2,88 +2,242 @@ package dev.blazelight.p4oc.ui.screens.home import dev.blazelight.p4oc.core.datastore.SavedServer import dev.blazelight.p4oc.core.network.ConnectionState +import dev.blazelight.p4oc.data.session.RepoState +import dev.blazelight.p4oc.domain.model.SessionPresence +import dev.blazelight.p4oc.domain.model.resolveSessionPresence import dev.blazelight.p4oc.domain.server.ServerRef import dev.blazelight.p4oc.domain.server.WorkspaceKey +import dev.blazelight.p4oc.domain.session.SessionId import dev.blazelight.p4oc.ui.tabs.TabInstance +/** A repository snapshot whose immutable owner is known by Home. */ +data class ScopedHomeRepositoryState( + val serverRef: ServerRef, + val state: RepoState, +) + data class ServerSummary( val serverRef: ServerRef, val displayName: String, val connectionState: ConnectionState, + val sessionCount: Int, val openTabCount: Int, + val isLoading: Boolean, + val failure: String? = null, ) data class WorkspaceSummary( val serverRef: ServerRef, val workspaceKey: WorkspaceKey, + val sessionCount: Int, val openTabCount: Int, + val mostRecentAt: Long, ) +enum class OpenWorkType { Chat, Files, Terminal } + data class OpenWorkSummary( val tabId: String, val serverRef: ServerRef, val workspaceKey: WorkspaceKey, - val route: String, + val type: OpenWorkType, + val title: String, + val status: SessionPresence? = null, +) + +data class SessionPreview( + val sessionId: SessionId, + val serverRef: ServerRef, + val workspaceKey: WorkspaceKey, + val title: String, + val updatedAt: Long, + val status: SessionPresence, + val directory: String, + val childCount: Int, + val additions: Int, + val deletions: Int, + val isShared: Boolean, ) data class HomeSummaryState( val servers: List, val workspaces: List, val openWork: List, + val sessions: List, + val isLoading: Boolean, val partialFailures: List = emptyList(), ) +data class HomeSummaryInput( + val savedServers: List, + val connectionStates: Map, + val tabs: List, + val repositories: List = emptyList(), + val workspaceLimit: Int = 12, + val openWorkLimit: Int = 24, +) + object HomeSummaryBuilder { - fun build( - savedServers: List, - connectionStates: Map, - tabs: List, - workspaceLimit: Int = 12, - openWorkLimit: Int = 24, - ): HomeSummaryState { - val workTabs = tabs.filterNot { it.isPinnedHome } + fun build(input: HomeSummaryInput): HomeSummaryState { + val workTabs = input.tabs.filterNot { it.isPinnedHome } val failures = mutableListOf() - val serverSummaries = savedServers.mapNotNull { saved -> - runCatching { - val serverRef = ServerRef.fromEndpointKey(saved.endpointKey, saved.displayName) - ServerSummary( - serverRef = serverRef, - displayName = saved.displayName, - connectionState = connectionStates[saved.endpointKey] ?: ConnectionState.Disconnected, - openTabCount = workTabs.count { it.serverEndpointKey == saved.endpointKey }, - ) - }.getOrElse { - failures += saved.endpointKey - null - } - } - val openWork = workTabs.mapNotNull { tab -> - val serverRef = tab.serverRef ?: return@mapNotNull null - val workspaceKey = tab.workspaceKey ?: return@mapNotNull null - OpenWorkSummary( - tabId = tab.id, - serverRef = serverRef, - workspaceKey = workspaceKey, - route = tab.startRoute, - ) - }.take(openWorkLimit) - val workspaces = openWork - .groupBy { it.serverRef.endpointKey to it.workspaceKey } - .values - .map { grouped -> - val first = grouped.first() - WorkspaceSummary( - serverRef = first.serverRef, - workspaceKey = first.workspaceKey, - openTabCount = grouped.size, - ) - } - .take(workspaceLimit) + val sessions = buildSessionPreviews(input.repositories) + val serverSummaries = buildServerSummaries(input, sessions, workTabs, failures) + appendRepositoryFailures(input.repositories, failures) + val openWork = buildOpenWork(workTabs, sessions, input.openWorkLimit) + val workspaces = buildWorkspaces(sessions, openWork, input.workspaceLimit) + return HomeSummaryState( servers = serverSummaries, workspaces = workspaces, openWork = openWork, - partialFailures = failures, + sessions = sessions, + isLoading = input.repositories.any { it.state is RepoState.Hydrating }, + partialFailures = failures.distinct(), ) } } + +private fun buildSessionPreviews(repositories: List): List = + repositories.flatMap { scoped -> + val sessions = scoped.state.snapshot.sessions.values + val childCounts = sessions.mapNotNull { it.session.parentID } + .groupingBy { it } + .eachCount() + sessions.map { workspaceSession -> + val session = workspaceSession.session + SessionPreview( + sessionId = workspaceSession.id, + serverRef = scoped.serverRef, + workspaceKey = workspaceSession.workspace.key, + title = session.title.ifBlank { "Untitled session" }, + updatedAt = session.updatedAt, + status = resolveSessionPresence(scoped.state.snapshot.statuses[session.id]), + directory = session.directory, + childCount = childCounts[session.id] ?: 0, + additions = session.summary?.additions ?: 0, + deletions = session.summary?.deletions ?: 0, + isShared = session.shareUrl != null, + ) + } + }.distinctBy { it.serverRef.endpointKey to it.sessionId.value } + .sortedByDescending { it.updatedAt } + +private fun buildServerSummaries( + input: HomeSummaryInput, + sessions: List, + workTabs: List, + failures: MutableList, +): List { + val repositoryByServer = input.repositories + .associateBy { it.serverRef.endpointKey } + return input.savedServers.mapNotNull { saved -> + runCatching { + val repository = repositoryByServer[saved.endpointKey] + ServerSummary( + serverRef = ServerRef.fromEndpointKey(saved.endpointKey, saved.displayName), + displayName = saved.displayName, + connectionState = input.connectionStates[saved.endpointKey] ?: ConnectionState.Disconnected, + sessionCount = sessions.count { it.serverRef.endpointKey == saved.endpointKey }, + openTabCount = workTabs.count { it.serverEndpointKey == saved.endpointKey }, + isLoading = repository?.state is RepoState.Hydrating, + failure = (repository?.state as? RepoState.Stale)?.reason, + ) + }.getOrElse { + failures += saved.displayName + null + } + } +} + +private fun appendRepositoryFailures( + repositories: List, + failures: MutableList, +) { + repositories.mapNotNullTo(failures) { scoped -> + (scoped.state as? RepoState.Stale)?.let { + "${scoped.serverRef.displayName}: ${it.reason ?: "session data unavailable"}" + } + } +} + +private fun buildOpenWork( + tabs: List, + sessions: List, + limit: Int, +): List = tabs.mapNotNull { it.toOpenWorkSummary(sessions) }.take(limit) + +private fun TabInstance.toOpenWorkSummary(sessions: List): OpenWorkSummary? { + val ownedServer = serverRef + val ownedWorkspace = workspaceKey + val type = openWorkType() + if (ownedServer == null || ownedWorkspace == null || type == null) return null + val matchingSession = sessionId?.let { sessionId -> + sessions.firstOrNull { session -> + session.sessionId.value == sessionId && + session.serverRef.endpointKey == ownedServer.endpointKey && + session.workspaceKey == ownedWorkspace + } + } + return OpenWorkSummary( + tabId = id, + serverRef = ownedServer, + workspaceKey = ownedWorkspace, + type = type, + title = openWorkTitle(type, matchingSession), + status = matchingSession?.status, + ) +} + +private fun TabInstance.openWorkType(): OpenWorkType? = when { + sessionId != null || startRoute.startsWith("chat/") -> OpenWorkType.Chat + startRoute.startsWith("files") -> OpenWorkType.Files + startRoute.startsWith("terminal/") -> OpenWorkType.Terminal + else -> null +} + +private fun TabInstance.openWorkTitle( + type: OpenWorkType, + matchingSession: SessionPreview?, +): String = when (type) { + OpenWorkType.Chat -> sessionTitle ?: matchingSession?.title ?: "Chat" + OpenWorkType.Files -> "Files" + OpenWorkType.Terminal -> "Terminal" +} + +private fun buildWorkspaces( + sessions: List, + openWork: List, + limit: Int, +): List { + val workspaceKeys = ( + sessions.map { it.serverRef to it.workspaceKey } + + openWork.map { it.serverRef to it.workspaceKey } + ).distinctBy { it.first.endpointKey to it.second } + return workspaceKeys.map { (serverRef, workspaceKey) -> + workspaceSummary(serverRef, workspaceKey, sessions, openWork) + }.sortedWith( + compareByDescending { it.mostRecentAt } + .thenBy { it.workspaceKey.toString() }, + ).take(limit) +} + +private fun workspaceSummary( + serverRef: ServerRef, + workspaceKey: WorkspaceKey, + sessions: List, + openWork: List, +): WorkspaceSummary { + val scopedSessions = sessions.filter { + it.serverRef.endpointKey == serverRef.endpointKey && it.workspaceKey == workspaceKey + } + return WorkspaceSummary( + serverRef = serverRef, + workspaceKey = workspaceKey, + sessionCount = scopedSessions.size, + openTabCount = openWork.count { + it.serverRef.endpointKey == serverRef.endpointKey && it.workspaceKey == workspaceKey + }, + mostRecentAt = scopedSessions.maxOfOrNull { it.updatedAt } ?: 0L, + ) +} diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerScreen.kt index df8e9226..6328fddd 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape @@ -31,12 +32,12 @@ import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextOverflow import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.blazelight.p4oc.R -import dev.blazelight.p4oc.core.datastore.RecentServer import dev.blazelight.p4oc.core.datastore.SavedServer import dev.blazelight.p4oc.core.network.DiscoveredServer import dev.blazelight.p4oc.core.network.DiscoveryState import dev.blazelight.p4oc.ui.components.TuiConfirmDialog import dev.blazelight.p4oc.ui.components.TuiLoadingIndicator +import dev.blazelight.p4oc.ui.components.status.serverStatusIndicator import dev.blazelight.p4oc.ui.tabs.TabManager import dev.blazelight.p4oc.ui.theme.LocalOpenCodeTheme import dev.blazelight.p4oc.ui.theme.Sizing @@ -46,17 +47,28 @@ import org.koin.compose.koinInject @OptIn(ExperimentalMaterial3Api::class) @Composable -fun ServerScreen( - viewModel: ServerViewModel = koinViewModel(), +fun serverScreen( onNavigateToSessions: () -> Unit, onNavigateToProjects: () -> Unit, - onSettings: () -> Unit + onSettings: () -> Unit, + autoReconnect: Boolean = true, + onConnectSavedServer: ((SavedServer) -> Unit)? = null, ) { + val viewModel: ServerViewModel = koinViewModel() val theme = LocalOpenCodeTheme.current val uiState by viewModel.uiState.collectAsStateWithLifecycle() val tabManager: TabManager = koinInject() val tabs by tabManager.tabs.collectAsState() - val openTabEndpointKeys = tabs.mapNotNull { it.serverEndpointKey }.toSet() + val openTabsByEndpoint = tabs.filterNot { it.isPinnedHome }.groupBy { it.serverEndpointKey } + val inventory = remember(uiState) { buildServerInventory(uiState) } + var showManualForm by rememberSaveable { + mutableStateOf(uiState.savedServers.isEmpty() && uiState.discoveredServers.isEmpty()) + } + var editingServerId by rememberSaveable { mutableStateOf(null) } + + LaunchedEffect(autoReconnect) { + viewModel.start(autoReconnect) + } // Start/stop mDNS discovery with screen lifecycle DisposableEffect(Unit) { @@ -80,6 +92,50 @@ fun ServerScreen( } } + serverScaffold( + presentation = ServerPresentation( + uiState = uiState, + inventory = inventory, + openTabsByEndpoint = openTabsByEndpoint, + tabManager = tabManager, + viewModel = viewModel, + showManualForm = showManualForm, + onShowManualForm = { showManualForm = true }, + editingServerId = editingServerId, + onEditServer = { saved -> + viewModel.prepareSavedServer(saved) + editingServerId = saved.id + }, + onDismissEdit = { editingServerId = null }, + onNavigateToSessions = onNavigateToSessions, + onConnectSavedServer = onConnectSavedServer, + ), + onSettings = onSettings, + ) +} + +private data class ServerPresentation( + val uiState: ServerUiState, + val inventory: ServerInventory, + val openTabsByEndpoint: Map>, + val tabManager: TabManager, + val viewModel: ServerViewModel, + val showManualForm: Boolean, + val onShowManualForm: () -> Unit, + val editingServerId: String?, + val onEditServer: (SavedServer) -> Unit, + val onDismissEdit: () -> Unit, + val onNavigateToSessions: () -> Unit, + val onConnectSavedServer: ((SavedServer) -> Unit)?, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun serverScaffold( + presentation: ServerPresentation, + onSettings: () -> Unit, +) { + val theme = LocalOpenCodeTheme.current Scaffold( topBar = { TopAppBar( @@ -87,431 +143,593 @@ fun ServerScreen( Text( "[ ${stringResource(R.string.server_connect_title)} ]", fontFamily = FontFamily.Monospace, - color = theme.text + color = theme.text, ) }, actions = { - IconButton( - onClick = onSettings, - modifier = Modifier.testTag("server_settings_button") - ) { - Text( - text = "⚙", - color = theme.textMuted, - fontFamily = FontFamily.Monospace + IconButton(onClick = onSettings, modifier = Modifier.testTag("server_settings_button")) { + Icon( + Icons.Default.Settings, + contentDescription = stringResource(R.string.server_settings_cd), + tint = theme.textMuted, ) } }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = theme.backgroundElement - ) + colors = TopAppBarDefaults.topAppBarColors(containerColor = theme.backgroundElement), ) }, - containerColor = theme.background + containerColor = LocalOpenCodeTheme.current.background, ) { padding -> - Column( - modifier = Modifier + serverContent( + Modifier .fillMaxSize() .padding(padding) .imePadding() .verticalScroll(rememberScrollState()) .padding(Spacing.md), - verticalArrangement = Arrangement.spacedBy(Spacing.md) - ) { - // Discovered servers section (mDNS) - if (uiState.discoveredServers.isNotEmpty() || uiState.discoveryState == DiscoveryState.SCANNING) { - DiscoveredServersSection( - servers = uiState.discoveredServers, - discoveryState = uiState.discoveryState, - isConnecting = uiState.isConnecting, - onServerClick = viewModel::connectToDiscoveredServer - ) - } + presentation, + ) + } +} - if (uiState.savedServers.isNotEmpty()) { - SavedServersSection( - servers = uiState.savedServers, - isConnecting = uiState.isConnecting, - openTabEndpointKeys = openTabEndpointKeys, - onServerClick = { saved -> - viewModel.setRemoteUrl(saved.endpoint) - viewModel.setUsername(saved.username ?: "opencode") - viewModel.setAllowInsecure(saved.allowInsecure) +private val serverContent: @Composable (Modifier, ServerPresentation) -> Unit = { modifier, presentation -> + Column(modifier, verticalArrangement = Arrangement.spacedBy(Spacing.md)) { + val openTabsByEndpoint = presentation.openTabsByEndpoint + if (presentation.inventory.saved.isNotEmpty()) { + savedServersSection( + state = SavedServersState( + presentation.inventory.saved, + presentation.uiState.isConnecting, + openTabsByEndpoint, + ), + actions = SavedServerActions( + onServerClick = presentation.onConnectSavedServer ?: presentation.viewModel::connectToSavedServer, + onEditServer = presentation.onEditServer, + onReviewTabs = { saved -> + openTabsByEndpoint[saved.endpointKey]?.firstOrNull()?.let { + presentation.tabManager.focusTab(it.id) + } + presentation.onNavigateToSessions() + }, + onCloseTabsAndRemove = { saved -> + openTabsByEndpoint[saved.endpointKey].orEmpty().forEach { + presentation.tabManager.closeTab(it.id) + } + presentation.viewModel.removeSavedServer(saved) }, - onRemoveServer = viewModel::removeSavedServer, + onRemoveServer = presentation.viewModel::removeSavedServer, + ), + ) + } + presentation.editingServerId?.let { id -> + presentation.inventory.saved.firstOrNull { it.server.id == id }?.server?.let { server -> + savedServerEditor( + SavedServerEditorPresentation( + server = server, + uiState = presentation.uiState, + viewModel = presentation.viewModel, + openTabCount = openTabsByEndpoint[server.endpointKey].orEmpty().size, + onReviewTabs = { + openTabsByEndpoint[server.endpointKey]?.firstOrNull()?.let { + presentation.tabManager.focusTab(it.id) + } + presentation.onNavigateToSessions() + }, + onCloseTabsAndRemove = { + openTabsByEndpoint[server.endpointKey].orEmpty().forEach { + presentation.tabManager.closeTab(it.id) + } + presentation.viewModel.removeSavedServer(server) + presentation.onDismissEdit() + }, + onDismiss = presentation.onDismissEdit, + ), ) } + } + val showDiscovery = presentation.inventory.nearby.isNotEmpty() || + presentation.uiState.discoveryState == DiscoveryState.SCANNING + if (showDiscovery) { + discoveredServersSection( + presentation.inventory.nearby, + presentation.uiState.discoveryState, + presentation.uiState.isConnecting, + presentation.viewModel::connectToDiscoveredServer, + ) + } + manualServerSection( + presentation.uiState, + presentation.viewModel, + presentation.showManualForm, + presentation.onShowManualForm, + ) + serverFooter(presentation.uiState.error) + } +} - if (uiState.recentServers.isNotEmpty()) { - RecentServersSection( - servers = uiState.recentServers, - isConnecting = uiState.isConnecting, - onServerClick = viewModel::connectToRecentServer, - onRemoveServer = viewModel::removeRecentServer - ) - } +@Composable +private fun serverFooter(error: String?) { + serverError(error) + serverSetupHelpSection() +} - RemoteServerSection( - url = uiState.remoteUrl, - username = uiState.username, - password = uiState.password, - allowInsecure = uiState.allowInsecure, - isConnecting = uiState.isConnecting, - onUrlChange = viewModel::setRemoteUrl, - onUsernameChange = viewModel::setUsername, - onPasswordChange = viewModel::setPassword, - onAllowInsecureChange = viewModel::setAllowInsecure, - onConnect = viewModel::connectToRemote - ) +@Composable +private fun manualServerSection( + uiState: ServerUiState, + viewModel: ServerViewModel, + showManualForm: Boolean, + onShowManualForm: () -> Unit, +) { + if (!showManualForm) { + OutlinedButton( + onClick = onShowManualForm, + modifier = Modifier.fillMaxWidth().testTag("server_add_button"), + shape = RectangleShape, + ) { + Icon(Icons.Default.Add, contentDescription = null) + Spacer(Modifier.width(Spacing.sm)) + Text(stringResource(R.string.server_add), fontFamily = FontFamily.Monospace) + } + } else { + remoteServerSection( + state = RemoteServerState( + uiState.remoteUrl, + uiState.username, + uiState.password, + uiState.allowInsecure, + uiState.isConnecting, + ), + actions = RemoteServerActions( + viewModel::setRemoteUrl, + viewModel::setUsername, + viewModel::setPassword, + viewModel::setAllowInsecure, + viewModel::connectToRemote, + ), + ) + } +} - uiState.error?.let { error -> - Surface( - color = theme.error.copy(alpha = 0.1f), - shape = RectangleShape, - modifier = Modifier.border(Sizing.strokeMd, theme.error.copy(alpha = 0.3f), RectangleShape) - ) { - Row( - modifier = Modifier.padding(Spacing.md), - horizontalArrangement = Arrangement.spacedBy(Spacing.sm), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = "✗", - color = theme.error, - fontFamily = FontFamily.Monospace - ) - Text( - text = error, - color = theme.error, - fontFamily = FontFamily.Monospace - ) - } - } +private val serverError: @Composable (String?) -> Unit = { error -> + val theme = LocalOpenCodeTheme.current + error?.let { + Surface( + color = theme.error.copy(alpha = 0.1f), + shape = RectangleShape, + modifier = Modifier.border(Sizing.strokeMd, theme.error.copy(alpha = 0.3f), RectangleShape), + ) { + Row( + modifier = Modifier.padding(Spacing.md), + horizontalArrangement = Arrangement.spacedBy(Spacing.sm), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.Default.Error, stringResource(R.string.server_status_cd_error), tint = theme.error) + Text(it, color = theme.error, fontFamily = FontFamily.Monospace) } - - ServerSetupHelpSection() } } } -internal fun serverUrlTextFieldValue(url: String): TextFieldValue = TextFieldValue( - text = url, - selection = TextRange(url.length), +internal fun serverUrlTextFieldValue(url: String): TextFieldValue = + TextFieldValue( + text = url, + selection = TextRange(url.length), + ) + +private data class RemoteServerState( + val url: String, + val username: String, + val password: String, + val allowInsecure: Boolean, + val isConnecting: Boolean, +) + +private data class RemoteServerActions( + val onUrlChange: (String) -> Unit, + val onUsernameChange: (String) -> Unit, + val onPasswordChange: (String) -> Unit, + val onAllowInsecureChange: (Boolean) -> Unit, + val onConnect: () -> Unit, ) @Composable -private fun RemoteServerSection( - url: String, - username: String, - password: String, - allowInsecure: Boolean, - isConnecting: Boolean, - onUrlChange: (String) -> Unit, - onUsernameChange: (String) -> Unit, - onPasswordChange: (String) -> Unit, - onAllowInsecureChange: (Boolean) -> Unit, - onConnect: () -> Unit +private fun remoteServerSection( + state: RemoteServerState, + actions: RemoteServerActions, ) { val theme = LocalOpenCodeTheme.current var passwordVisible by remember { mutableStateOf(false) } - var urlFieldValue by remember { mutableStateOf(serverUrlTextFieldValue(url)) } + var showCredentials by rememberSaveable { mutableStateOf(false) } + var urlFieldValue by remember { mutableStateOf(serverUrlTextFieldValue(state.url)) } - LaunchedEffect(url) { - if (url != urlFieldValue.text) { - urlFieldValue = serverUrlTextFieldValue(url) + LaunchedEffect(state.url) { + if (state.url != urlFieldValue.text) { + urlFieldValue = serverUrlTextFieldValue(state.url) } } Surface( color = theme.backgroundElement, - shape = RectangleShape + shape = RectangleShape, ) { Column( modifier = Modifier.padding(Spacing.md), - verticalArrangement = Arrangement.spacedBy(Spacing.sm) + verticalArrangement = Arrangement.spacedBy(Spacing.sm), ) { Text( text = "[ ${stringResource(R.string.server_remote_title)} ]", style = MaterialTheme.typography.titleMedium, fontFamily = FontFamily.Monospace, - color = theme.text + color = theme.text, ) - Text( text = stringResource(R.string.server_remote_description), style = MaterialTheme.typography.bodyMedium, fontFamily = FontFamily.Monospace, - color = theme.textMuted + color = theme.textMuted, ) - - OutlinedTextField( + remoteUrlField( value = urlFieldValue, onValueChange = { value -> urlFieldValue = value - onUrlChange(value.text) + actions.onUrlChange(value.text) }, - label = { Text(stringResource(R.string.field_server_url), fontFamily = FontFamily.Monospace) }, - placeholder = { Text( - stringResource(R.string.field_server_url_placeholder), - fontFamily = FontFamily.Monospace - ) }, - singleLine = true, - modifier = Modifier.fillMaxWidth().testTag("server_url_input"), - shape = RectangleShape, - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = theme.accent, - unfocusedBorderColor = theme.border - ) ) + credentialsSection( + state = state, + actions = actions, + controls = CredentialControls( + expanded = showCredentials, + passwordVisible = passwordVisible, + onToggleExpanded = { showCredentials = !showCredentials }, + onTogglePassword = { passwordVisible = !passwordVisible }, + ), + ) + connectButton(state = state, onConnect = actions.onConnect) + } + } +} - OutlinedTextField( - value = username, - onValueChange = onUsernameChange, - label = { Text(stringResource(R.string.field_username), fontFamily = FontFamily.Monospace) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - shape = RectangleShape, - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = theme.accent, - unfocusedBorderColor = theme.border - ) +@Composable +private fun remoteUrlField( + value: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit, +) { + val theme = LocalOpenCodeTheme.current + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { Text(stringResource(R.string.field_server_url), fontFamily = FontFamily.Monospace) }, + placeholder = { + Text( + stringResource(R.string.field_server_url_placeholder), + fontFamily = FontFamily.Monospace, ) + }, + singleLine = true, + modifier = Modifier.fillMaxWidth().testTag("server_url_input"), + shape = RectangleShape, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = theme.accent, + unfocusedBorderColor = theme.border, + ), + ) +} - OutlinedTextField( - value = password, - onValueChange = onPasswordChange, - label = { Text(stringResource(R.string.field_password), fontFamily = FontFamily.Monospace) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - visualTransformation = if (passwordVisible) { - VisualTransformation.None - } else { - PasswordVisualTransformation() - }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), - shape = RectangleShape, - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = theme.accent, - unfocusedBorderColor = theme.border - ), - trailingIcon = { - Text( - text = if (passwordVisible) "◉" else "○", - color = theme.textMuted, - fontFamily = FontFamily.Monospace, - modifier = Modifier.clickable(role = Role.Button) { passwordVisible = !passwordVisible } - ) +private data class CredentialControls( + val expanded: Boolean, + val passwordVisible: Boolean, + val onToggleExpanded: () -> Unit, + val onTogglePassword: () -> Unit, +) + +@Composable +private fun credentialsSection( + state: RemoteServerState, + actions: RemoteServerActions, + controls: CredentialControls, +) { + TextButton( + onClick = controls.onToggleExpanded, + modifier = Modifier.testTag("server_credentials_toggle"), + ) { + Icon( + if (controls.expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = null, + ) + Text(stringResource(R.string.server_credentials), fontFamily = FontFamily.Monospace) + } + AnimatedVisibility(controls.expanded) { + credentialsFields(state, actions, controls.passwordVisible, controls.onTogglePassword) + } +} + +@Composable +private fun credentialsFields( + state: RemoteServerState, + actions: RemoteServerActions, + passwordVisible: Boolean, + onTogglePassword: () -> Unit, +) { + val theme = LocalOpenCodeTheme.current + Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm)) { + OutlinedTextField( + value = state.username, + onValueChange = actions.onUsernameChange, + label = { Text(stringResource(R.string.field_username), fontFamily = FontFamily.Monospace) }, + singleLine = true, + modifier = Modifier.fillMaxWidth().testTag("server_username_input"), + shape = RectangleShape, + ) + passwordField(state.password, actions.onPasswordChange, passwordVisible, onTogglePassword) + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(role = Role.Checkbox) { + actions.onAllowInsecureChange(!state.allowInsecure) } + .padding(vertical = Spacing.xs) + .testTag("server_allow_insecure_toggle"), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.sm), + ) { + Checkbox( + checked = state.allowInsecure, + onCheckedChange = actions.onAllowInsecureChange, + colors = CheckboxDefaults.colors(checkedColor = theme.accent), ) - - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(role = Role.Checkbox) { onAllowInsecureChange(!allowInsecure) } - .padding(vertical = Spacing.xs) - .testTag("server_allow_insecure_toggle"), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(Spacing.sm) - ) { + Column(Modifier.weight(1f)) { Text( - text = if (allowInsecure) "[x]" else "[ ]", + stringResource(R.string.field_allow_insecure), fontFamily = FontFamily.Monospace, - color = theme.accent + color = theme.text, + ) + Text( + stringResource(R.string.field_allow_insecure_desc), + fontFamily = FontFamily.Monospace, + color = theme.textMuted, + style = MaterialTheme.typography.bodySmall, ) - Column(modifier = Modifier.weight(1f)) { - Text( - text = stringResource(R.string.field_allow_insecure), - fontFamily = FontFamily.Monospace, - color = theme.text, - style = MaterialTheme.typography.bodyMedium - ) - Text( - text = stringResource(R.string.field_allow_insecure_desc), - fontFamily = FontFamily.Monospace, - color = theme.textMuted, - style = MaterialTheme.typography.bodySmall - ) - } } + } + } +} - Button( - onClick = onConnect, - enabled = url.isNotBlank() && !isConnecting, - modifier = Modifier.fillMaxWidth().testTag("server_connect_button"), - shape = RectangleShape, - colors = ButtonDefaults.buttonColors( - containerColor = theme.accent, - contentColor = theme.background - ) +@Composable +private fun passwordField( + password: String, + onPasswordChange: (String) -> Unit, + passwordVisible: Boolean, + onTogglePassword: () -> Unit, +) { + val theme = LocalOpenCodeTheme.current + OutlinedTextField( + value = password, + onValueChange = onPasswordChange, + label = { Text(stringResource(R.string.field_password), fontFamily = FontFamily.Monospace) }, + singleLine = true, + modifier = Modifier.fillMaxWidth().testTag("server_password_input"), + visualTransformation = if (passwordVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + shape = RectangleShape, + trailingIcon = { + IconButton( + onClick = onTogglePassword, + modifier = Modifier.testTag("server_password_visibility"), ) { - if (isConnecting) { - TuiLoadingIndicator() - Spacer(Modifier.width(Spacing.md)) - Text(stringResource(R.string.button_connecting), fontFamily = FontFamily.Monospace) - } else { - Text("→ ${stringResource(R.string.button_connect)}", fontFamily = FontFamily.Monospace) - } + Icon( + if (passwordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility, + contentDescription = stringResource(R.string.server_password_visibility_cd), + tint = theme.textMuted, + ) } + }, + ) +} + +@Composable +private fun connectButton(state: RemoteServerState, onConnect: () -> Unit) { + val theme = LocalOpenCodeTheme.current + Button( + onClick = onConnect, + enabled = state.url.isNotBlank() && !state.isConnecting, + modifier = Modifier.fillMaxWidth().testTag("server_connect_button"), + shape = RectangleShape, + colors = ButtonDefaults.buttonColors( + containerColor = theme.accent, + contentColor = theme.background, + ), + ) { + if (state.isConnecting) { + TuiLoadingIndicator() + Spacer(Modifier.width(Spacing.md)) + Text(stringResource(R.string.button_connecting), fontFamily = FontFamily.Monospace) + } else { + Icon(Icons.Default.Login, contentDescription = null) + Spacer(Modifier.width(Spacing.sm)) + Text(stringResource(R.string.button_connect), fontFamily = FontFamily.Monospace) } } } @Composable -private fun ServerSetupHelpSection() { +private fun serverSetupHelpSection() { val theme = LocalOpenCodeTheme.current var expanded by remember { mutableStateOf(false) } Surface( color = theme.backgroundElement, - shape = RectangleShape + shape = RectangleShape, ) { Column( modifier = Modifier.padding(Spacing.md), - verticalArrangement = Arrangement.spacedBy(Spacing.sm) + verticalArrangement = Arrangement.spacedBy(Spacing.sm), ) { // Header — always visible, acts as toggle Row( - modifier = Modifier + modifier = + Modifier .fillMaxWidth() .clickable(role = Role.Button) { expanded = !expanded }, horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { Text( text = "[ ? ${stringResource(R.string.server_setup_title)} ]", style = MaterialTheme.typography.titleMedium, fontFamily = FontFamily.Monospace, - color = theme.text + color = theme.text, ) Text( text = if (expanded) "▾" else "▸", fontFamily = FontFamily.Monospace, - color = theme.textMuted + color = theme.textMuted, ) } + setupHelpContent(expanded) + } + } +} + +@Composable +private fun setupHelpContent(expanded: Boolean) { + val theme = LocalOpenCodeTheme.current + Text( + text = stringResource(R.string.server_setup_subtitle), + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = theme.textMuted, + ) + AnimatedVisibility(visible = expanded) { + Column(verticalArrangement = Arrangement.spacedBy(Spacing.md)) { + Spacer(Modifier.height(Spacing.xs)) + setupStep( + number = "1", + title = stringResource(R.string.server_setup_step1_title), + command = stringResource(R.string.server_setup_step1_cmd), + ) + setupStep( + number = "2", + title = stringResource(R.string.server_setup_step2_title), + command = stringResource(R.string.server_setup_step2_cmd), + ) + setupStep( + number = "3", + title = stringResource(R.string.server_setup_step3_title), + command = stringResource(R.string.server_setup_step3_cmd), + ) + setupHelpTip() + } + } +} + +private val setupHelpTip: @Composable () -> Unit = { + val theme = LocalOpenCodeTheme.current + Surface( + color = theme.accent.copy(alpha = 0.08f), + shape = RectangleShape, + modifier = Modifier.border( + Sizing.strokeThin, + theme.accent.copy(alpha = 0.3f), + RectangleShape, + ), + ) { + Column( + modifier = Modifier.padding(Spacing.md), + verticalArrangement = Arrangement.spacedBy(Spacing.xs), + ) { Text( - text = stringResource(R.string.server_setup_subtitle), - style = MaterialTheme.typography.bodySmall, + text = "── ${stringResource(R.string.server_setup_tip_label)} ──", fontFamily = FontFamily.Monospace, - color = theme.textMuted + color = theme.accent, + style = MaterialTheme.typography.labelMedium, ) - - AnimatedVisibility(visible = expanded) { - Column( - verticalArrangement = Arrangement.spacedBy(Spacing.md) - ) { - Spacer(Modifier.height(Spacing.xs)) - - SetupStep( - number = "1", - title = stringResource(R.string.server_setup_step1_title), - command = stringResource(R.string.server_setup_step1_cmd) - ) - - SetupStep( - number = "2", - title = stringResource(R.string.server_setup_step2_title), - command = stringResource(R.string.server_setup_step2_cmd) - ) - - SetupStep( - number = "3", - title = stringResource(R.string.server_setup_step3_title), - command = stringResource(R.string.server_setup_step3_cmd) - ) - - // Tip box - Surface( - color = theme.accent.copy(alpha = 0.08f), - shape = RectangleShape, - modifier = Modifier.border( - Sizing.strokeThin, - theme.accent.copy(alpha = 0.3f), - RectangleShape - ) - ) { - Column( - modifier = Modifier.padding(Spacing.md), - verticalArrangement = Arrangement.spacedBy(Spacing.xs) - ) { - Text( - text = "── ${stringResource(R.string.server_setup_tip_label)} ──", - fontFamily = FontFamily.Monospace, - color = theme.accent, - style = MaterialTheme.typography.labelMedium - ) - Text( - text = stringResource(R.string.server_setup_tip_text), - fontFamily = FontFamily.Monospace, - color = theme.textMuted, - style = MaterialTheme.typography.bodySmall - ) - SetupCodeBlock( - command = stringResource(R.string.server_setup_find_ip) - ) - Text( - text = stringResource(R.string.server_setup_test_hint), - fontFamily = FontFamily.Monospace, - color = theme.textMuted, - style = MaterialTheme.typography.bodySmall - ) - } - } - } + Text( + text = stringResource(R.string.server_setup_tip_text), + fontFamily = FontFamily.Monospace, + color = theme.textMuted, + style = MaterialTheme.typography.bodySmall, + ) + Surface( + color = theme.background, + shape = RectangleShape, + modifier = Modifier.border(Sizing.strokeThin, theme.border, RectangleShape), + ) { + Text( + text = stringResource(R.string.server_setup_find_ip), + modifier = Modifier.fillMaxWidth().padding(Spacing.sm), + fontFamily = FontFamily.Monospace, + color = theme.accent, + style = MaterialTheme.typography.bodySmall, + ) } + Text( + text = stringResource(R.string.server_setup_test_hint), + fontFamily = FontFamily.Monospace, + color = theme.textMuted, + style = MaterialTheme.typography.bodySmall, + ) } } } @Composable -private fun SetupStep(number: String, title: String, command: String) { +private fun setupStep( + number: String, + title: String, + command: String, +) { val theme = LocalOpenCodeTheme.current Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs)) { Text( text = "$number. $title", fontFamily = FontFamily.Monospace, color = theme.text, - style = MaterialTheme.typography.bodyMedium + style = MaterialTheme.typography.bodyMedium, ) - SetupCodeBlock(command = command) + Surface( + color = theme.background, + shape = RectangleShape, + modifier = Modifier.border(Sizing.strokeThin, theme.border, RectangleShape), + ) { + Text( + text = command, + modifier = Modifier.fillMaxWidth().padding(Spacing.sm), + fontFamily = FontFamily.Monospace, + color = theme.accent, + style = MaterialTheme.typography.bodySmall, + ) + } } } -@Composable -private fun SetupCodeBlock(command: String) { - val theme = LocalOpenCodeTheme.current - Surface( - color = theme.background, - shape = RectangleShape, - modifier = Modifier.border(Sizing.strokeThin, theme.border, RectangleShape) - ) { - Text( - text = command, - modifier = Modifier - .fillMaxWidth() - .padding(Spacing.sm), - fontFamily = FontFamily.Monospace, - color = theme.accent, - style = MaterialTheme.typography.bodySmall - ) - } -} +private data class SavedServersState( + val servers: List, + val isConnecting: Boolean, + val openTabsByEndpoint: Map>, +) + +private data class SavedServerActions( + val onServerClick: (SavedServer) -> Unit, + val onEditServer: (SavedServer) -> Unit, + val onReviewTabs: (SavedServer) -> Unit, + val onCloseTabsAndRemove: (SavedServer) -> Unit, + val onRemoveServer: (SavedServer) -> Unit, +) @Composable -private fun SavedServersSection( - servers: List, - isConnecting: Boolean, - openTabEndpointKeys: Set, - onServerClick: (SavedServer) -> Unit, - onRemoveServer: (SavedServer) -> Unit, +private fun savedServersSection( + state: SavedServersState, + actions: SavedServerActions, ) { val theme = LocalOpenCodeTheme.current var pendingForget by remember { mutableStateOf?>(null) } - Surface( - color = theme.backgroundElement, - shape = RectangleShape, - ) { + Surface(color = theme.backgroundElement, shape = RectangleShape) { Column( modifier = Modifier.padding(Spacing.md), verticalArrangement = Arrangement.spacedBy(Spacing.xs), @@ -528,110 +746,79 @@ private fun SavedServersSection( fontFamily = FontFamily.Monospace, color = theme.textMuted, ) - servers.forEach { server -> - key(server.id) { - var menuExpanded by remember { mutableStateOf(false) } - val tlsLabel = if (server.allowInsecure) { - stringResource(R.string.server_tls_checks_off) - } else { - stringResource(R.string.server_tls_checks_on) - } - val openTabCount = openTabEndpointKeys.count { it == server.endpointKey } - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(enabled = !isConnecting, role = Role.Button) { onServerClick(server) } - .padding(vertical = Spacing.md), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(Spacing.lg), - ) { - Text( - text = "[${server.badgeLabel}]", - style = MaterialTheme.typography.labelSmall, - fontFamily = FontFamily.Monospace, - color = theme.textMuted, - ) - Column(modifier = Modifier.weight(1f)) { - Text( - text = server.displayName, - style = MaterialTheme.typography.bodyMedium, - fontFamily = FontFamily.Monospace, - color = theme.text, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = "${server.endpoint} · $tlsLabel", - style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace, - color = theme.textMuted, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - if (openTabCount > 0) { - Text( - text = stringResource(R.string.server_open_tabs_count, openTabCount), - style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace, - color = theme.warning, - ) - } - } - Box { - IconButton( - onClick = { menuExpanded = true }, - enabled = !isConnecting, - modifier = Modifier.testTag("saved_server_actions_${server.id}"), - ) { - Icon( - Icons.Default.MoreVert, - contentDescription = stringResource( - R.string.server_actions_for, - server.displayName, - ), - tint = theme.textMuted, - ) - } - DropdownMenu( - expanded = menuExpanded, - onDismissRequest = { menuExpanded = false }, - ) { - DropdownMenuItem( - text = { Text(stringResource(R.string.server_forget)) }, - onClick = { - menuExpanded = false - pendingForget = server to openTabCount - }, - leadingIcon = { - Icon( - Icons.Default.Delete, - contentDescription = null, - tint = theme.error, - ) - }, - ) - } - } - } + state.servers.forEach { entry -> + key(entry.server.id) { + savedServerRow( + entry = entry, + isConnecting = state.isConnecting, + openTabCount = state.openTabsByEndpoint[entry.server.endpointKey].orEmpty().size, + actions = actions, + onForget = { server, count -> pendingForget = server to count }, + ) } } - pendingForget?.let { (server, openTabCount) -> - TuiConfirmDialog( - onDismissRequest = { pendingForget = null }, - onConfirm = { - pendingForget = null - onRemoveServer(server) + } + } + pendingForget?.let { (server, count) -> + forgetServerDialog( + server = server, + openTabCount = count, + actions = actions, + onDismiss = { pendingForget = null }, + ) + } +} + +@Composable +private fun savedServerRow( + entry: ServerInventoryEntry, + isConnecting: Boolean, + openTabCount: Int, + actions: SavedServerActions, + onForget: (SavedServer, Int) -> Unit, +) { + val theme = LocalOpenCodeTheme.current + val server = entry.server + var menuExpanded by remember { mutableStateOf(false) } + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = !isConnecting, role = Role.Button) { + actions.onServerClick(server) + } + .padding(vertical = Spacing.sm), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.md), + ) { + Text("[${server.badgeLabel}]", fontFamily = FontFamily.Monospace, color = theme.textMuted) + savedServerDetails(entry, openTabCount, Modifier.weight(1f)) + Box { + IconButton( + onClick = { menuExpanded = true }, + modifier = Modifier.testTag("saved_server_actions_${server.id}"), + ) { + Icon( + Icons.Default.MoreVert, + stringResource(R.string.server_actions_for, server.displayName), + tint = theme.textMuted, + ) + } + DropdownMenu(menuExpanded, { menuExpanded = false }) { + DropdownMenuItem( + text = { Text(stringResource(R.string.server_edit)) }, + onClick = { + menuExpanded = false + actions.onEditServer(server) }, - title = stringResource(R.string.server_forget_title, server.displayName), - message = if (openTabCount > 0) { - stringResource(R.string.server_forget_message, openTabCount) - } else { - stringResource(R.string.server_forget_message_no_tabs) + leadingIcon = { Icon(Icons.Default.Edit, null) }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.server_forget)) }, + onClick = { + menuExpanded = false + onForget(server, openTabCount) }, - confirmText = stringResource(R.string.server_forget), - dismissText = stringResource(R.string.button_cancel), - isDestructive = true, - modifier = Modifier.testTag("saved_server_forget_dialog"), + leadingIcon = { Icon(Icons.Default.Delete, null, tint = theme.error) }, ) } } @@ -639,103 +826,221 @@ private fun SavedServersSection( } @Composable -private fun RecentServersSection( - servers: List, - isConnecting: Boolean, - onServerClick: (RecentServer) -> Unit, - onRemoveServer: (RecentServer) -> Unit +private fun savedServerDetails( + entry: ServerInventoryEntry, + openTabCount: Int, + modifier: Modifier, ) { val theme = LocalOpenCodeTheme.current - - Surface( - color = theme.backgroundElement, - shape = RectangleShape - ) { - Column( - modifier = Modifier.padding(Spacing.md), - verticalArrangement = Arrangement.spacedBy(Spacing.xs) - ) { + val server = entry.server + Column(modifier) { + Text(server.displayName, fontFamily = FontFamily.Monospace, color = theme.text, maxLines = 1) + Text( + server.endpoint, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = theme.textMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + serverStatusIndicator(entry.status) + Text( + if (server.username.isNullOrBlank()) { + stringResource(R.string.server_auth_default) + } else { + stringResource(R.string.server_auth_configured) + }, + style = MaterialTheme.typography.bodySmall, + color = theme.textMuted, + ) + Text( + if (server.allowInsecure) { + stringResource(R.string.server_tls_checks_off) + } else { + stringResource(R.string.server_tls_checks_on) + }, + style = MaterialTheme.typography.bodySmall, + color = if (server.allowInsecure) theme.warning else theme.textMuted, + ) + if (openTabCount > 0) { Text( - text = "[ ${stringResource(R.string.server_recent_servers)} ]", - style = MaterialTheme.typography.titleMedium, - fontFamily = FontFamily.Monospace, - color = theme.text + stringResource(R.string.server_open_tabs_count, openTabCount), + style = MaterialTheme.typography.bodySmall, + color = theme.warning, ) + } + } +} - servers.forEach { server -> - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(enabled = !isConnecting, role = Role.Button) { onServerClick(server) } - .padding(vertical = Spacing.md), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(Spacing.lg) - ) { - Text( - text = "◇", - color = theme.textMuted, - fontFamily = FontFamily.Monospace - ) - Column(modifier = Modifier.weight(1f)) { - Text( - text = server.name, - style = MaterialTheme.typography.bodyMedium, - fontFamily = FontFamily.Monospace, - color = theme.text, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = server.url, - style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace, - color = theme.textMuted, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - Text( - text = "×", - color = theme.textMuted, - fontFamily = FontFamily.Monospace, - modifier = Modifier.clickable(role = Role.Button) { onRemoveServer(server) } - ) - } - } +@Composable +private fun savedServerEditor(presentation: SavedServerEditorPresentation) { + val server = presentation.server + val uiState = presentation.uiState + val viewModel = presentation.viewModel + val theme = LocalOpenCodeTheme.current + var confirmRemoval by remember { mutableStateOf(false) } + Surface(color = theme.backgroundElement, shape = RectangleShape) { + savedServerEditorForm(presentation) { confirmRemoval = true } + } + if (confirmRemoval) { + savedServerRemovalDialog(presentation) { confirmRemoval = false } + } +} + +@Composable +private fun savedServerEditorForm(presentation: SavedServerEditorPresentation, onRemove: () -> Unit) { + val server = presentation.server + val uiState = presentation.uiState + val viewModel = presentation.viewModel + val theme = LocalOpenCodeTheme.current + Column(Modifier.padding(Spacing.md), verticalArrangement = Arrangement.spacedBy(Spacing.sm)) { + Row(Modifier.fillMaxWidth(), Arrangement.SpaceBetween, Alignment.CenterVertically) { + Text("[ ${server.displayName} ]", fontFamily = FontFamily.Monospace, color = theme.text) + IconButton(onClick = presentation.onDismiss) { Icon(Icons.Default.Close, "Close server details") } + } + remoteServerSection( + RemoteServerState( + uiState.remoteUrl, + uiState.username, + uiState.password, + uiState.allowInsecure, + uiState.isConnecting, + ), + RemoteServerActions( + viewModel::setRemoteUrl, + viewModel::setUsername, + viewModel::setPassword, + viewModel::setAllowInsecure, + viewModel::connectToRemote, + ), + ) + OutlinedButton( + onClick = { viewModel.saveSavedServer(server) }, + enabled = uiState.remoteUrl.isNotBlank() && !uiState.isConnecting, + modifier = Modifier.fillMaxWidth().testTag("saved_server_save"), + shape = RectangleShape, + ) { + Icon(Icons.Default.Save, null) + Spacer(Modifier.width(Spacing.sm)) + Text("Save", fontFamily = FontFamily.Monospace) + } + TextButton(onRemove, Modifier.fillMaxWidth().testTag("saved_server_detail_forget")) { + Text(stringResource(R.string.server_forget), color = theme.error) } } } @Composable -private fun DiscoveredServersSection( +private fun savedServerRemovalDialog(presentation: SavedServerEditorPresentation, onDismiss: () -> Unit) { + val server = presentation.server + val viewModel = presentation.viewModel + forgetServerDialog( + server = server, + openTabCount = presentation.openTabCount, + actions = SavedServerActions( + onServerClick = {}, + onEditServer = {}, + onReviewTabs = { presentation.onReviewTabs() }, + onCloseTabsAndRemove = { presentation.onCloseTabsAndRemove() }, + onRemoveServer = { + viewModel.removeSavedServer(server) + presentation.onDismiss() + }, + ), + onDismiss = onDismiss, + ) +} + +private data class SavedServerEditorPresentation( + val server: SavedServer, + val uiState: ServerUiState, + val viewModel: ServerViewModel, + val openTabCount: Int, + val onReviewTabs: () -> Unit, + val onCloseTabsAndRemove: () -> Unit, + val onDismiss: () -> Unit, +) + +@Composable +private fun forgetServerDialog( + server: SavedServer, + openTabCount: Int, + actions: SavedServerActions, + onDismiss: () -> Unit, +) { + val theme = LocalOpenCodeTheme.current + if (openTabCount == 0) { + TuiConfirmDialog( + onDismissRequest = onDismiss, + onConfirm = { + onDismiss() + actions.onRemoveServer(server) + }, + title = stringResource(R.string.server_forget_title, server.displayName), + message = stringResource(R.string.server_forget_message_no_tabs), + confirmText = stringResource(R.string.server_forget), + dismissText = stringResource(R.string.button_cancel), + isDestructive = true, + modifier = Modifier.testTag("saved_server_forget_dialog"), + ) + } else { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.server_forget_title, server.displayName)) }, + text = { Text(stringResource(R.string.server_forget_message, openTabCount)) }, + confirmButton = { + TextButton( + onClick = { + onDismiss() + actions.onReviewTabs(server) + }, + modifier = Modifier.testTag("server_review_tabs"), + ) { Text(stringResource(R.string.server_review_tabs)) } + }, + dismissButton = { + TextButton( + onClick = { + onDismiss() + actions.onCloseTabsAndRemove(server) + }, + modifier = Modifier.testTag("server_close_tabs_forget"), + ) { Text(stringResource(R.string.server_close_tabs_forget), color = theme.error) } + }, + modifier = Modifier.testTag("saved_server_forget_dialog"), + ) + } +} + +@Composable +private fun discoveredServersSection( servers: List, discoveryState: DiscoveryState, isConnecting: Boolean, - onServerClick: (DiscoveredServer) -> Unit + onServerClick: (DiscoveredServer) -> Unit, ) { val theme = LocalOpenCodeTheme.current Surface( color = theme.backgroundElement, - shape = RectangleShape + shape = RectangleShape, ) { Column( modifier = Modifier.padding(Spacing.md), - verticalArrangement = Arrangement.spacedBy(Spacing.xs) + verticalArrangement = Arrangement.spacedBy(Spacing.xs), ) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { Text( text = "[ ${stringResource(R.string.discovery_section_title)} ]", style = MaterialTheme.typography.titleMedium, fontFamily = FontFamily.Monospace, - color = theme.text + color = theme.text, ) if (discoveryState == DiscoveryState.SCANNING) { - ScanningIndicator() + scanningIndicator() } } @@ -744,74 +1049,87 @@ private fun DiscoveredServersSection( text = stringResource(R.string.discovery_scanning_hint), style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace, - color = theme.textMuted + color = theme.textMuted, ) } servers.forEach { server -> - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(enabled = !isConnecting, role = Role.Button) { - onServerClick(server) - } - .testTag("discovered_server_${server.serviceName}") - .padding(vertical = Spacing.md), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(Spacing.lg) - ) { - Text( - text = "●", - color = theme.success, - fontFamily = FontFamily.Monospace - ) - Column(modifier = Modifier.weight(1f)) { - Text( - text = server.serviceName, - style = MaterialTheme.typography.bodyMedium, - fontFamily = FontFamily.Monospace, - color = theme.text, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = "${server.host}:${server.port}", - style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace, - color = theme.textMuted, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - Text( - text = "→", - color = theme.textMuted, - fontFamily = FontFamily.Monospace - ) - } + discoveredServerRow(server, isConnecting, onServerClick) } } } } @Composable -private fun ScanningIndicator() { +private fun discoveredServerRow( + server: DiscoveredServer, + isConnecting: Boolean, + onServerClick: (DiscoveredServer) -> Unit, +) { + val theme = LocalOpenCodeTheme.current + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = !isConnecting, role = Role.Button) { + onServerClick(server) + } + .testTag("discovered_server_${server.serviceName}") + .padding(vertical = Spacing.md), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.lg), + ) { + serverStatusIndicator(ServerConnectionStatus.AVAILABLE) + Column(modifier = Modifier.weight(1f)) { + Text( + text = server.serviceName, + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + color = theme.text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "${server.host}:${server.port}", + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = theme.textMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Icon( + Icons.Default.ChevronRight, + contentDescription = stringResource(R.string.button_connect), + tint = theme.textMuted, + ) + } +} + +private val scanningIndicator: @Composable () -> Unit = { val theme = LocalOpenCodeTheme.current val infiniteTransition = rememberInfiniteTransition(label = "scanning") val alpha by infiniteTransition.animateFloat( initialValue = 0.3f, targetValue = 1.0f, - animationSpec = infiniteRepeatable( + animationSpec = + infiniteRepeatable( animation = tween(durationMillis = 800), - repeatMode = RepeatMode.Reverse + repeatMode = RepeatMode.Reverse, ), - label = "scanPulse" + label = "scanPulse", ) - Text( - text = "● ${stringResource(R.string.discovery_scanning)}", - style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace, - color = theme.accent.copy(alpha = alpha) - ) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.xs)) { + CircularProgressIndicator( + Modifier.size(Sizing.indicatorDotActive), + color = theme.accent.copy(alpha = alpha), + strokeWidth = Sizing.strokeMd, + ) + Text( + stringResource(R.string.discovery_scanning), + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = theme.accent.copy(alpha = alpha), + ) + } } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerViewModel.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerViewModel.kt index c8f40ac9..1277a909 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerViewModel.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerViewModel.kt @@ -21,6 +21,43 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +enum class ServerConnectionStatus { CONNECTED, CONNECTING, AVAILABLE, DISCONNECTED, ERROR } + +data class ServerInventoryEntry( + val server: SavedServer, + val discovered: DiscoveredServer?, + val status: ServerConnectionStatus, +) + +data class ServerInventory( + val saved: List, + val nearby: List, +) + +internal fun buildServerInventory(state: ServerUiState): ServerInventory { + val discoveredByEndpoint = state.discoveredServers.associateBy { + ServerUrl.endpointKey(it.url) ?: it.url.trim() + } + val saved = state.savedServers.distinctBy(SavedServer::endpointKey).map { server -> + val discovered = discoveredByEndpoint[server.endpointKey] + val status = when { + state.connectedEndpointKey == server.endpointKey && state.isConnected -> ServerConnectionStatus.CONNECTED + state.connectingEndpointKey == server.endpointKey && state.isConnecting -> ServerConnectionStatus.CONNECTING + state.failedEndpointKey == server.endpointKey -> ServerConnectionStatus.ERROR + discovered != null -> ServerConnectionStatus.AVAILABLE + else -> ServerConnectionStatus.DISCONNECTED + } + ServerInventoryEntry(server, discovered, status) + } + val savedKeys = saved.mapTo(mutableSetOf()) { it.server.endpointKey } + return ServerInventory( + saved = saved, + nearby = state.discoveredServers.filter { + (ServerUrl.endpointKey(it.url) ?: it.url.trim()) !in savedKeys + }, + ) +} + private const val TAG = "ServerViewModel" class ServerViewModel constructor( @@ -33,11 +70,15 @@ class ServerViewModel constructor( private val _uiState = MutableStateFlow(ServerUiState()) val uiState: StateFlow = _uiState.asStateFlow() - init { + private var started = false + + fun start(autoReconnect: Boolean) { + if (started) return + started = true loadRecentServers() loadSavedServers() collectDiscoveryFlows() - tryAutoReconnect() + if (autoReconnect) tryAutoReconnect() } private fun loadRecentServers() { @@ -61,9 +102,11 @@ class ServerViewModel constructor( val (lastConfig, password) = settingsDataStore.getLastConnection() ?: return@launch AppLog.d(TAG, "Found last connection: ${lastConfig.url}") + val endpointKey = ServerUrl.endpointKey(lastConfig.url) _uiState.update { it.copy( isConnecting = true, + connectingEndpointKey = endpointKey, remoteUrl = lastConfig.url, username = lastConfig.username ?: ServerUrl.DEFAULT_USERNAME, password = password ?: "", @@ -77,13 +120,23 @@ class ServerViewModel constructor( onSuccess = { projects -> AppLog.d(TAG, "Auto-reconnect successful") initializeProjectContext() - _uiState.update { it.copy(isConnecting = false, isConnected = true) } + _uiState.update { + it.copy( + isConnecting = false, + isConnected = true, + connectingEndpointKey = null, + connectedEndpointKey = endpointKey, + failedEndpointKey = null, + ) + } }, onFailure = { error -> AppLog.w(TAG, "Auto-reconnect failed: ${error.message}") _uiState.update { it.copy( isConnecting = false, + connectingEndpointKey = null, + failedEndpointKey = endpointKey, error = "Could not reconnect: ${error.message}" ) } @@ -119,14 +172,21 @@ class ServerViewModel constructor( } viewModelScope.launch { - _uiState.update { it.copy(isConnecting = true, error = null) } - val url = ServerUrl.normalizeConnectUrl(state.remoteUrl) if (url == null) { AppLog.w(TAG, "Invalid server URL: '${state.remoteUrl}'") _uiState.update { it.copy(isConnecting = false, error = "Invalid server URL") } return@launch } + val endpointKey = ServerUrl.endpointKey(url) + _uiState.update { + it.copy( + isConnecting = true, + connectingEndpointKey = endpointKey, + failedEndpointKey = null, + error = null, + ) + } AppLog.d(TAG, "Connecting to normalized URL: $url") val identity = ServerIdentity.derive(url, state.serverNameCandidate) @@ -163,7 +223,15 @@ class ServerViewModel constructor( lastConnectedAt = System.currentTimeMillis(), ) initializeProjectContext() - _uiState.update { it.copy(isConnecting = false, isConnected = true) } + _uiState.update { + it.copy( + isConnecting = false, + isConnected = true, + connectingEndpointKey = null, + connectedEndpointKey = endpointKey, + failedEndpointKey = null, + ) + } }, onFailure = { error -> AppLog.e(TAG, "Connection failed: ${error.message}", error) @@ -171,6 +239,8 @@ class ServerViewModel constructor( _uiState.update { it.copy( isConnecting = false, + connectingEndpointKey = null, + failedEndpointKey = endpointKey, password = "", error = "Failed to connect: ${error.message}" ) @@ -202,6 +272,51 @@ class ServerViewModel constructor( } } + fun prepareSavedServer(server: SavedServer) { + val password = credentialStore.getServerPassword(server.id) + ?: credentialStore.getServerPassword(server.endpoint) + ?: "" + _uiState.update { + it.copy( + remoteUrl = server.endpoint, + serverNameCandidate = server.displayName, + username = server.username ?: ServerUrl.DEFAULT_USERNAME, + password = password, + allowInsecure = server.allowInsecure, + error = null, + ) + } + } + + fun connectToSavedServer(server: SavedServer) { + prepareSavedServer(server) + connectToRemote() + } + + fun saveSavedServer(server: SavedServer) { + val state = _uiState.value + val url = ServerUrl.normalizeConnectUrl(state.remoteUrl) + if (url == null) { + _uiState.update { it.copy(error = "Invalid server URL") } + return + } + val identity = ServerIdentity.derive(url, state.serverNameCandidate ?: server.displayName) + viewModelScope.launch { + val updated = settingsDataStore.addSavedServer( + url = url, + name = identity.displayName, + username = state.username.takeIf(String::isNotBlank), + password = state.password.takeIf(String::isNotBlank), + allowInsecure = state.allowInsecure, + pinned = server.pinned, + defaultWorkspace = server.defaultWorkspace, + lastConnectedAt = server.lastConnectedAt, + ) + if (updated.id != server.id) settingsDataStore.removeSavedServer(server.id) + _uiState.update { it.copy(remoteUrl = updated.endpoint, password = "", error = null) } + } + } + fun removeSavedServer(server: SavedServer) { viewModelScope.launch { settingsDataStore.removeSavedServer(server.id) @@ -274,6 +389,9 @@ data class ServerUiState( val isConnecting: Boolean = false, val isConnected: Boolean = false, val error: String? = null, + val connectingEndpointKey: String? = null, + val connectedEndpointKey: String? = null, + val failedEndpointKey: String? = null, val recentServers: List = emptyList(), val savedServers: List = emptyList(), val discoveredServers: List = emptyList(), diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/sessions/SessionListScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/sessions/SessionListScreen.kt index 461f7ef9..eecdc04b 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/sessions/SessionListScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/sessions/SessionListScreen.kt @@ -5,8 +5,6 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn @@ -14,7 +12,6 @@ import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material3.* -import androidx.compose.material3.MenuAnchorType import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment @@ -36,14 +33,11 @@ import dev.blazelight.p4oc.R import dev.blazelight.p4oc.domain.model.Session import dev.blazelight.p4oc.domain.model.SessionPresence import dev.blazelight.p4oc.domain.model.SessionStatus -import dev.blazelight.p4oc.ui.components.TuiAlertDialog -import dev.blazelight.p4oc.ui.components.TuiButton import dev.blazelight.p4oc.ui.components.TuiConfirmDialog import dev.blazelight.p4oc.ui.components.TuiDropdownMenuItem import dev.blazelight.p4oc.ui.components.TuiInputDialog import dev.blazelight.p4oc.ui.components.TuiLoadingIndicator import dev.blazelight.p4oc.ui.components.TuiSnackbar -import dev.blazelight.p4oc.ui.components.TuiTextButton import dev.blazelight.p4oc.ui.components.TuiTopBar import dev.blazelight.p4oc.ui.components.status.SessionStatusRow import dev.blazelight.p4oc.ui.theme.LocalOpenCodeTheme @@ -84,8 +78,6 @@ fun SessionListScreen( onNavigateBack: (() -> Unit)? = null ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - var showNewSessionDialog by remember { mutableStateOf(false) } - var showNewSessionCustomDir by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(null) } var showRenameDialog by remember { mutableStateOf(null) } var showSearch by rememberSaveable { mutableStateOf(false) } @@ -125,7 +117,7 @@ fun SessionListScreen( LaunchedEffect(autoCreateSession, autoCreateSessionTitle, autoCreateSessionDirectory) { if (autoCreateSession) { onAutoCreateSessionConsumed() - viewModel.createSession(title = autoCreateSessionTitle, directory = autoCreateSessionDirectory) + onCreateSessionInWorkspace(autoCreateSessionTitle, autoCreateSessionDirectory) } } @@ -267,51 +259,6 @@ fun SessionListScreen( ) } } - // Pinned quick actions (hidden while searching) - if (!searchActive && filterProjectId == null) { - item(key = "quick_action_global") { - QuickActionCard( - icon = "\u25C6", - title = stringResource(R.string.sessions_quick_global), - subtitle = stringResource(R.string.sessions_quick_global_desc), - contentDescription = stringResource(R.string.cd_new_session), - onClick = { - onCreateSessionInWorkspace(null, null) - }, - modifier = Modifier.testTag("quick_action_global") - ) - } - - item(key = "quick_action_custom") { - QuickActionCard( - icon = "\u25C7", - title = stringResource(R.string.sessions_quick_custom), - subtitle = stringResource(R.string.sessions_quick_custom_desc), - contentDescription = stringResource(R.string.cd_new_session), - onClick = { - showNewSessionCustomDir = true - showNewSessionDialog = true - }, - modifier = Modifier.testTag("quick_action_custom") - ) - } - } else if (!searchActive) { - filterDirectory?.let { directory -> - item(key = "quick_action_project") { - QuickActionCard( - icon = "\u25C6", - title = stringResource( - R.string.sessions_create_in_project, - projectName ?: directory.substringAfterLast("/") - ), - subtitle = directory, - contentDescription = stringResource(R.string.cd_new_session), - onClick = { onCreateSessionInWorkspace(null, directory) }, - modifier = Modifier.testTag("project_new_session_button") - ) - } - } - } if (searchActive && sessionTree.isEmpty()) { item(key = "no_match") { @@ -326,26 +273,31 @@ fun SessionListScreen( modifier = Modifier.padding(horizontal = Spacing.md, vertical = Spacing.lg) ) } - } else if (displayedSessions.isEmpty() && filterProjectId == null) { - item(key = "empty_hint") { - Text( - text = stringResource(R.string.sessions_empty_hint), - style = MaterialTheme.typography.bodySmall, - color = theme.textMuted, - modifier = Modifier.padding(horizontal = Spacing.md, vertical = Spacing.lg) - ) - } } else if (displayedSessions.isEmpty()) { item(key = "empty_hint") { Column( - modifier = Modifier.padding(horizontal = Spacing.md, vertical = Spacing.lg), - verticalArrangement = Arrangement.spacedBy(Spacing.md) + modifier = Modifier + .testTag("sessions_empty_state") + .padding(horizontal = Spacing.md, vertical = Spacing.lg), + verticalArrangement = Arrangement.spacedBy(Spacing.xs), ) { Text( text = stringResource(R.string.sessions_empty_title), + style = MaterialTheme.typography.bodyMedium, + color = theme.text, + ) + Text( + text = stringResource(R.string.sessions_empty_hint), style = MaterialTheme.typography.bodySmall, color = theme.textMuted, ) + filterDirectory?.let { directory -> + Text( + text = directory, + style = MaterialTheme.typography.labelSmall, + color = theme.textMuted, + ) + } } } } else { @@ -403,23 +355,6 @@ fun SessionListScreen( } } - if (showNewSessionDialog) { - NewSessionDialog( - projects = uiState.projects, - defaultProjectId = filteredProject?.id, - initialUseCustomDirectory = showNewSessionCustomDir, - onDismiss = { - showNewSessionDialog = false - showNewSessionCustomDir = false - }, - onCreate = { title, directory -> - onCreateSessionInWorkspace(title, directory) - showNewSessionDialog = false - showNewSessionCustomDir = false - } - ) - } - showDeleteDialog?.let { session -> TuiConfirmDialog( onDismissRequest = { showDeleteDialog = null }, @@ -663,14 +598,25 @@ private fun SessionCard( ) { val theme = LocalOpenCodeTheme.current var showContextMenu by remember { mutableStateOf(false) } + val workspaceIdentity = projectName?.takeIf { it.isNotBlank() } + ?: session.directory.takeIf { it.isNotBlank() } + ?: stringResource(R.string.sessions_no_project_context) + val resumeIdentity = stringResource( + R.string.sessions_resume_identity, + session.title, + workspaceIdentity, + formatDateTime(session.updatedAt), + ) Surface( modifier = Modifier .fillMaxWidth() + .testTag("session_resume_${session.id}") + .semantics { contentDescription = resumeIdentity } .combinedClickable( onClick = onClick, onLongClick = { showContextMenu = true }, - role = Role.Button + role = Role.Button, ), color = when { presence == SessionPresence.BUSY -> theme.accent.copy(alpha = 0.1f) @@ -899,223 +845,6 @@ private fun SessionStatusIndicator(status: SessionStatus?, presence: SessionPres } } -@Composable -private fun QuickActionCard( - icon: String, - title: String, - subtitle: String, - contentDescription: String, - onClick: () -> Unit, - modifier: Modifier = Modifier -) { - val theme = LocalOpenCodeTheme.current - Surface( - modifier = modifier - .fillMaxWidth() - .semantics { this.contentDescription = contentDescription } - .clickable(role = Role.Button, onClick = onClick), - color = theme.background, - shape = RectangleShape - ) { - Row( - modifier = Modifier - .border(Sizing.strokeThin, theme.accent, RectangleShape) - .padding(horizontal = Spacing.md, vertical = Spacing.sm) - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(Spacing.sm) - ) { - Text( - text = "+", - style = MaterialTheme.typography.bodyMedium, - color = theme.accent - ) - Text( - text = icon, - style = MaterialTheme.typography.bodyMedium, - color = theme.accent - ) - Column(modifier = Modifier.weight(1f)) { - Text( - text = title, - style = MaterialTheme.typography.bodyMedium, - color = theme.text - ) - Text( - text = subtitle, - style = MaterialTheme.typography.bodySmall, - color = theme.textMuted - ) - } - Text( - text = "\u2192", - style = MaterialTheme.typography.bodyMedium, - color = theme.textMuted - ) - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun NewSessionDialog( - projects: List, - defaultProjectId: String? = null, - initialUseCustomDirectory: Boolean = false, - onDismiss: () -> Unit, - onCreate: (String?, String?) -> Unit -) { - var title by remember { mutableStateOf("") } - // Default to null (Global) unless a specific project is requested - var selectedProject by remember(defaultProjectId, projects) { - mutableStateOf( - if (defaultProjectId != null && !initialUseCustomDirectory) { - projects.find { - it.id == defaultProjectId - } - } else { - null - } - ) - } - var expanded by remember { mutableStateOf(false) } - var useCustomDirectory by remember { mutableStateOf(initialUseCustomDirectory) } - var customDirectory by remember { mutableStateOf("") } - - val globalText = stringResource(R.string.sessions_global) - val customText = stringResource(R.string.sessions_custom_directory) - - // Resolve the effective directory for session creation - val effectiveDirectory = when { - useCustomDirectory -> customDirectory.takeIf { it.isNotBlank() } - else -> selectedProject?.worktree - } - - TuiAlertDialog( - onDismissRequest = onDismiss, - title = stringResource(R.string.sessions_new), - confirmButton = { - TuiButton( - onClick = { onCreate(title.takeIf { it.isNotBlank() }, effectiveDirectory) } - ) { - Text(stringResource(R.string.sessions_create)) - } - }, - dismissButton = { - TuiTextButton(onClick = onDismiss) { - Text(stringResource(R.string.button_cancel)) - } - } - ) { - ExposedDropdownMenuBox( - expanded = expanded, - onExpandedChange = { expanded = it } - ) { - OutlinedTextField( - value = when { - useCustomDirectory -> customText - selectedProject != null -> selectedProject!!.name - else -> globalText - }, - onValueChange = {}, - readOnly = true, - label = { Text(stringResource(R.string.sessions_project)) }, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, - modifier = Modifier - .fillMaxWidth() - .menuAnchor(MenuAnchorType.PrimaryNotEditable, enabled = true) - ) - ExposedDropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false } - ) { - val theme = LocalOpenCodeTheme.current - // Global option first - DropdownMenuItem( - text = { - Column { - Text(stringResource(R.string.sessions_global), style = MaterialTheme.typography.bodyMedium) - Text( - stringResource(R.string.sessions_no_project_context), - style = MaterialTheme.typography.bodySmall, - color = theme.textMuted - ) - } - }, - onClick = { - selectedProject = null - useCustomDirectory = false - expanded = false - } - ) - - // Project options - projects.forEach { project -> - DropdownMenuItem( - text = { - Column { - Text(project.name, style = MaterialTheme.typography.bodyMedium) - Text( - project.worktree, - style = MaterialTheme.typography.bodySmall, - color = theme.textMuted - ) - } - }, - onClick = { - selectedProject = project - useCustomDirectory = false - expanded = false - } - ) - } - - // Custom directory option - DropdownMenuItem( - text = { - Column { - Text( - stringResource(R.string.sessions_custom_directory), - style = MaterialTheme.typography.bodyMedium - ) - Text( - stringResource(R.string.sessions_custom_directory_desc), - style = MaterialTheme.typography.bodySmall, - color = theme.textMuted - ) - } - }, - onClick = { - useCustomDirectory = true - selectedProject = null - expanded = false - } - ) - } - } - - // Show custom directory text field when selected - if (useCustomDirectory) { - OutlinedTextField( - value = customDirectory, - onValueChange = { customDirectory = it }, - label = { Text(stringResource(R.string.sessions_directory_path)) }, - placeholder = { Text(stringResource(R.string.sessions_directory_hint)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - } - - OutlinedTextField( - value = title, - onValueChange = { title = it }, - label = { Text(stringResource(R.string.sessions_title_optional)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - } -} - private fun formatDateTime(epochMillis: Long): String { val instant = Instant.fromEpochMilliseconds(epochMillis) val local = instant.toLocalDateTime(TimeZone.currentSystemDefault()) diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/sessions/SessionListViewModel.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/sessions/SessionListViewModel.kt index 89bb4d1f..49993b51 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/sessions/SessionListViewModel.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/sessions/SessionListViewModel.kt @@ -85,15 +85,19 @@ class SessionListViewModel constructor( null }, sessions = snapshot.sessions.values - .map { workspaceSession -> workspaceSession.session.toSessionWithProject( - snapshot.projects - ) } + .map { workspaceSession -> + workspaceSession.session.toSessionWithProject( + snapshot.projects + ) + } .sortedByDescending { it.session.updatedAt }, projects = snapshot.projects.map(::toProjectInfo).sortedByDescending { it.worktree }, sessionStatuses = snapshot.statuses, - sessionPresences = snapshot.statuses.mapValues { (_, status) -> resolveSessionPresence( - status - ) }, + sessionPresences = snapshot.statuses.mapValues { (_, status) -> + resolveSessionPresence( + status + ) + }, searchResults = if (state.searchQuery.isBlank()) emptyList() else state.searchResults, error = (repoState as? RepoState.Stale)?.reason ?: state.error, ) @@ -121,17 +125,21 @@ class SessionListViewModel constructor( loadingProgress = null, loadingCounts = null, sessions = snapshot.sessions.values - .map { workspaceSession -> workspaceSession.session.toSessionWithProject( - snapshot.projects - ) } + .map { workspaceSession -> + workspaceSession.session.toSessionWithProject( + snapshot.projects + ) + } .sortedByDescending { session -> session.session.updatedAt }, projects = snapshot.projects.map( ::toProjectInfo ).sortedByDescending { project -> project.worktree }, sessionStatuses = snapshot.statuses, - sessionPresences = snapshot.statuses.mapValues { (_, status) -> resolveSessionPresence( - status - ) }, + sessionPresences = snapshot.statuses.mapValues { (_, status) -> + resolveSessionPresence( + status + ) + }, error = null, ) } @@ -268,13 +276,15 @@ class SessionListViewModel constructor( _uiState.update { it.copy(isLoading = true, loadingText = "Creating session", error = null) } try { if (directory != null && directory != sessionRepository.workspace.directory) { - _uiState.update { it.copy( - isLoading = false, - loadingText = null, - loadingProgress = null, - loadingCounts = null, - error = "Switch to $directory before creating a session" - ) } + _uiState.update { + it.copy( + isLoading = false, + loadingText = null, + loadingProgress = null, + loadingCounts = null, + error = "Switch to $directory before creating a session" + ) + } return@launch } val created = sessionRepository.createSession(title) diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/VisualSettingsScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/VisualSettingsScreen.kt index 0861cc6c..891e2254 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/VisualSettingsScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/VisualSettingsScreen.kt @@ -242,7 +242,6 @@ fun VisualSettingsScreen( onCheckedChange = { viewModel.toggleOpenSubAgentInNewTab() }, icon = Icons.Default.Tab ) - } SettingsSection(title = stringResource(R.string.visual_settings_tool_mode_label)) { diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/MainTabScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/MainTabScreen.kt index a9d6ba30..b15761e6 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/MainTabScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/MainTabScreen.kt @@ -3,30 +3,73 @@ package dev.blazelight.p4oc.ui.tabs import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.material.icons.filled.* -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.NavHostController import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController +import dev.blazelight.p4oc.R +import dev.blazelight.p4oc.core.datastore.SavedServer import dev.blazelight.p4oc.core.datastore.SettingsDataStore import dev.blazelight.p4oc.core.log.AppLog import dev.blazelight.p4oc.core.network.ApiResult import dev.blazelight.p4oc.core.network.ConnectionManager import dev.blazelight.p4oc.core.network.ConnectionState +import dev.blazelight.p4oc.core.network.ServerConnectionRegistry import dev.blazelight.p4oc.core.network.safeApiCall +import dev.blazelight.p4oc.core.network.toServerRef import dev.blazelight.p4oc.data.remote.dto.CreatePtyRequest +import dev.blazelight.p4oc.data.remote.dto.CreateSessionRequest import dev.blazelight.p4oc.data.session.SessionRepositoryProvider import dev.blazelight.p4oc.data.session.presence import dev.blazelight.p4oc.domain.model.SessionConnectionState @@ -35,731 +78,1055 @@ import dev.blazelight.p4oc.domain.server.ServerRef import dev.blazelight.p4oc.domain.server.WorkspaceKey import dev.blazelight.p4oc.domain.session.SessionId import dev.blazelight.p4oc.domain.workspace.Workspace -import dev.blazelight.p4oc.ui.components.TuiAlertDialog -import dev.blazelight.p4oc.ui.components.TuiTextButton import dev.blazelight.p4oc.ui.navigation.Screen import dev.blazelight.p4oc.ui.screens.home.HomeActions import dev.blazelight.p4oc.ui.screens.home.HomeSummaryBuilder +import dev.blazelight.p4oc.ui.screens.home.HomeSummaryInput +import dev.blazelight.p4oc.ui.screens.home.ScopedHomeRepositoryState import dev.blazelight.p4oc.ui.screens.home.homeScreen import dev.blazelight.p4oc.ui.theme.LocalOpenCodeTheme import dev.blazelight.p4oc.ui.theme.Spacing import dev.blazelight.p4oc.ui.theme.TuiShapes import dev.blazelight.p4oc.ui.workspace.WorkspaceRepositoryOwner +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import org.koin.compose.koinInject private const val TAG = "MainTabScreen" -/** - * Main container for the tab-based UI. - * Shows TabBar at top and active tab's content below. - */ +private data class SavedServerView( + val endpointKey: String, + val displayName: String, + val badgeLabel: String, +) + +private data class MainTabDeps( + val tabManager: TabManager, + val connectionManager: ConnectionManager, + val settingsDataStore: SettingsDataStore, + val serverConnectionRegistry: ServerConnectionRegistry, + val sessionRepositoryProvider: SessionRepositoryProvider, + val coroutineScope: CoroutineScope, +) + +private class StartWorkUiState { + var restoreError: String? by mutableStateOf(null) + var startWorkContext: StartWorkContext? by mutableStateOf(null) + var showStartWorkSheet: Boolean by mutableStateOf(false) + var showFilesTabPrompt: Boolean by mutableStateOf(false) + var showStartWorkPicker: Boolean by mutableStateOf(false) + var homeDetailSelection: StartWorkSelection by mutableStateOf(StartWorkSelection.NeedsSelection) + var pendingStartWork: Pair? by mutableStateOf(null) + var collapsedPickerServers: Set by mutableStateOf(emptySet()) +} + +internal data class StartWorkPickerGroup( + val server: ServerRef, + val badgeLabel: String, + val targets: List, +) + +internal fun buildStartWorkPickerGroups( + servers: List>, + openTargets: List, + knownHomeTargets: List, +): List = servers.map { (endpointKey, displayName, badgeLabel) -> + val server = ServerRef.fromEndpointKey(endpointKey, displayName) + val directoryTargets = (knownHomeTargets + openTargets) + .filter { it.serverRef.endpointKey == endpointKey && it.workspaceKey != WorkspaceKey.Global } + .distinctBy { it.workspaceKey } + StartWorkPickerGroup( + server = server, + badgeLabel = badgeLabel, + targets = listOf(StartWorkTarget(server, WorkspaceKey.Global)) + directoryTargets, + ) +} + +internal val startWorkScopedActionOrder = listOf( + StartWorkAction.NewChat, + StartWorkAction.Files, + StartWorkAction.Terminal, +) + +private class TabStateMaps( + val connectionStates: SnapshotStateMap, + val readTokens: SnapshotStateMap, + val routes: SnapshotStateMap, + val ptyIds: SnapshotStateMap, + val workspaceOwners: SnapshotStateMap, +) + +private data class MainTabContentParams( + val deps: MainTabDeps, + val uiState: StartWorkUiState, + val tabMaps: TabStateMaps, + val tabs: List, + val activeTabId: String?, + val savedServers: List, + val savedServerViews: List, + val scopedConnectionStates: Map, + val closeTab: (String) -> Unit, + val savedServerExists: (String) -> Boolean, + val connectSavedServer: (String) -> Unit, + val onDisconnect: () -> Unit, +) + @Composable -fun MainTabScreen( - onDisconnect: () -> Unit, - modifier: Modifier = Modifier -) { +private fun rememberMainTabDeps(): MainTabDeps { val tabManager: TabManager = koinInject() val connectionManager: ConnectionManager = koinInject() val settingsDataStore: SettingsDataStore = koinInject() + val serverConnectionRegistry: ServerConnectionRegistry = koinInject() val sessionRepositoryProvider: SessionRepositoryProvider = koinInject() val coroutineScope = rememberCoroutineScope() - val theme = LocalOpenCodeTheme.current - val lifecycleOwner = LocalLifecycleOwner.current - - val tabs by tabManager.tabs.collectAsState() - val activeTabId by tabManager.activeTabId.collectAsState() - val showTabWarning by tabManager.showTabWarning.collectAsState() - val connectionState by connectionManager.connectionState.collectAsState() - val currentServerRef = remember(connectionManager.currentBaseUrl) { - connectionManager.currentBaseUrl?.let { ServerRef.fromEndpoint(it) } - } - val savedServers by settingsDataStore.savedServers.collectAsState(initial = emptyList()) - val homeConnectionStates = remember(savedServers, connectionState, currentServerRef?.endpointKey) { - savedServers.associate { savedServer -> - savedServer.endpointKey to if (savedServer.endpointKey == currentServerRef?.endpointKey) { - connectionState - } else { - ConnectionState.Disconnected - } - } + return remember(coroutineScope) { + MainTabDeps( + tabManager = tabManager, + connectionManager = connectionManager, + settingsDataStore = settingsDataStore, + serverConnectionRegistry = serverConnectionRegistry, + sessionRepositoryProvider = sessionRepositoryProvider, + coroutineScope = coroutineScope, + ) } +} - var wasEverConnected by remember { mutableStateOf(false) } +private val rememberStartWorkUiState: @Composable () -> StartWorkUiState = { + remember { StartWorkUiState() } +} - LaunchedEffect(Unit) { - tabManager.ensureHomeTab(focus = false) +private val rememberTabStateMaps: @Composable () -> TabStateMaps = { + remember { + TabStateMaps( + connectionStates = mutableStateMapOf(), + readTokens = mutableStateMapOf(), + routes = mutableStateMapOf(), + ptyIds = mutableStateMapOf(), + workspaceOwners = mutableStateMapOf(), + ) } - var restoreError by remember { mutableStateOf(null) } - var showStartWorkSheet by remember { mutableStateOf(false) } - var showFilesTabPrompt by remember { mutableStateOf(false) } - var homeDetailSelection by remember { - mutableStateOf(StartWorkSelection.NeedsSelection) +} + +private val rememberScopedConnectionStates: @Composable ( + List, + ServerConnectionRegistry, +) -> Map = { savedServers, registry -> + savedServers.associate { saved -> + val serverRef = ServerRef.fromEndpointKey(saved.endpointKey, saved.displayName) + val state by registry.connectionState(serverRef).collectAsState() + saved.endpointKey to state } +} - // Foreground resume is delegated to ConnectionManager so reconnect policy - // has one owner instead of competing UI timers and SSE retry callbacks. - LaunchedEffect(lifecycleOwner) { - lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { - connectionManager.onAppForegrounded() +private val rememberSavedServerExists: @Composable (List) -> (String) -> Boolean = + { savedServers -> + remember(savedServers) { + fun(endpointKey: String): Boolean { + return savedServers.any { it.endpointKey == endpointKey } + } } } - // ConnectionManager owns SSE retry timeout and escalation. The UI only - // reacts to terminal disconnected states after a successful connection. - LaunchedEffect(connectionState) { - if (connectionState is ConnectionState.Connected) { - wasEverConnected = true - return@LaunchedEffect +private val rememberConnectSavedServer: @Composable ( + ServerConnectionRegistry, + List, +) -> (String) -> Unit = { registry, savedServers -> + remember(registry, savedServers) { + fun(endpointKey: String) { + savedServers.firstOrNull { it.endpointKey == endpointKey }?.let(registry::connect) } - if (!wasEverConnected) return@LaunchedEffect - if (connectionState is ConnectionState.Disconnected && tabs.isNotEmpty()) { - connectionManager.disconnect() - onDisconnect() + } +} + +private val mainTabForegroundEffect: @Composable (ConnectionManager, LifecycleOwner) -> Unit = + { connectionManager, lifecycleOwner -> + LaunchedEffect(lifecycleOwner) { + lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + connectionManager.onAppForegrounded() + } } } - LaunchedEffect(connectionManager.currentBaseUrl) { - val baseUrl = connectionManager.currentBaseUrl ?: return@LaunchedEffect - if (tabManager.shouldAttemptRestore()) { - val persisted = settingsDataStore.getPersistedTabState() - if (persisted != null) { - when (val result = tabManager.restoreState(persisted, ServerRef.fromEndpoint(baseUrl))) { - is RestoreResult.Restored -> AppLog.d(TAG, "Restored ${result.count} tabs") - RestoreResult.Empty -> AppLog.w(TAG, "Persisted tab state was empty") - is RestoreResult.VersionMismatch -> { - restoreError = "Saved tabs use unsupported version ${result.version}. Starting fresh." - } - is RestoreResult.ServerMismatch -> { - restoreError = "Saved tabs belong to ${result.persistedEndpointKey}, not ${result.activeEndpointKey}. Starting fresh." - } - is RestoreResult.MissingServer -> { - restoreError = "Saved tabs reference unavailable server ${result.endpointKey}. Starting fresh." - } - } +@Composable +private fun mainTabRestoreEffect( + deps: MainTabDeps, + savedServers: List, + uiState: StartWorkUiState, +) { + LaunchedEffect(savedServers) { + if (!deps.tabManager.shouldAttemptRestore()) return@LaunchedEffect + val persisted = deps.settingsDataStore.getPersistedTabState() + if (persisted != null) { + val availableServers = savedServers.associate { saved -> + saved.endpointKey to ServerRef.fromEndpointKey(saved.endpointKey, saved.displayName) } - if (!tabManager.hasTabs()) { - tabManager.ensureHomeTab(focus = true) + when (val result = deps.tabManager.restoreState(persisted, availableServers)) { + is RestoreResult.Restored -> AppLog.d(TAG, "Restored ${result.count} tabs") + RestoreResult.Empty -> AppLog.w(TAG, "Persisted tab state was empty") + is RestoreResult.VersionMismatch -> + uiState.restoreError = "Saved tabs use unsupported version ${result.version}. Starting fresh." + is RestoreResult.ServerMismatch -> + uiState.restoreError = "Saved tabs reference a different server. Starting fresh." + is RestoreResult.MissingServer -> + uiState.restoreError = "Saved tabs reference unavailable server ${result.endpointKey}." } } + if (!deps.tabManager.hasTabs()) deps.tabManager.ensureHomeTab(focus = true) } +} - LaunchedEffect(tabs, activeTabId, connectionManager.currentBaseUrl) { - val baseUrl = connectionManager.currentBaseUrl ?: return@LaunchedEffect - val state = tabManager.saveState(ServerRef.fromEndpoint(baseUrl)) ?: return@LaunchedEffect - settingsDataStore.setPersistedTabState(state) +@Composable +private fun mainTabPersistEffect( + deps: MainTabDeps, + tabs: List, + activeTabId: String?, +) { + LaunchedEffect(tabs, activeTabId) { + deps.settingsDataStore.setPersistedTabState(deps.tabManager.saveState()) } +} - val tabTitleLabels = rememberTabTitleLabels() - // Build tab titles and icons from current routes (updated inside pager pages). - // Seed from startRoute so titles are correct even when pages are off-screen. - val tabTitles = remember { mutableStateMapOf() } - val tabIcons = remember { mutableStateMapOf() } - tabs.forEach { tab -> - if (tab.id !in tabTitles) { - tabTitles[tab.id] = getTitleForRoute( - route = tab.startRoute, - labels = tabTitleLabels, - sessionTitle = tab.sessionTitle, - workspaceKey = tab.workspaceKey, - ) - tabIcons[tab.id] = getIconForRoute(tab.startRoute) +private val savedServerConnectionEffect: @Composable (ServerConnectionRegistry, List) -> Unit = + { registry, savedServers -> + val endpointKeys = savedServers.map(SavedServer::endpointKey) + LaunchedEffect(endpointKeys) { + savedServers.forEach { server -> + if (registry.connectionState(server.toServerRef()).value is ConnectionState.Disconnected) { + registry.connect(server) + } + } } } - val tabConnectionStates = remember { mutableStateMapOf() } - val tabReadTokens = remember { mutableStateMapOf() } - // Track current routes per tab (for PTY cleanup on tab close) - val tabRoutes = remember { mutableStateMapOf() } - val tabPtyIds = remember { mutableStateMapOf() } - val connection = connectionManager.connection.collectAsState().value - val baseUrl = connection?.config?.url - val generation = connection?.generation - val workspaceOwners = remember { mutableStateMapOf() } +@Composable +private fun mainTabWorkspaceOwnersEffect( + deps: MainTabDeps, + tabs: List, + savedServers: List, + scopedConnectionStates: Map, + workspaceOwners: SnapshotStateMap, +) { DisposableEffect(Unit) { onDispose { workspaceOwners.values.forEach { it.close() } workspaceOwners.clear() } } - - LaunchedEffect(tabs, baseUrl, generation) { - if (baseUrl == null || generation == null) { - workspaceOwners.values.forEach { it.close() } - workspaceOwners.clear() - return@LaunchedEffect + val tabOwnerInputs = tabs.mapNotNull { tab -> + val serverRef = tab.serverRef ?: return@mapNotNull null + val workspaceKey = tab.workspaceKey ?: return@mapNotNull null + val connection by deps.serverConnectionRegistry.connection(serverRef).collectAsState() + val generation = deps.serverConnectionRegistry.generation(serverRef) + TabOwnerInput(tab.id, serverRef, workspaceKey, connection != null, generation) + } + val homeOwnerInputs = savedServers.mapNotNull { saved -> + if (scopedConnectionStates[saved.endpointKey] !is ConnectionState.Connected) { + return@mapNotNull null } + val serverRef = ServerRef.fromEndpointKey(saved.endpointKey, saved.displayName) + TabOwnerInput( + tabId = "home:${saved.endpointKey}", + serverRef = serverRef, + workspaceKey = WorkspaceKey.Global, + connected = true, + generation = deps.serverConnectionRegistry.generation(serverRef), + ) + } + val ownerInputs = tabOwnerInputs + homeOwnerInputs + LaunchedEffect(ownerInputs) { + reconcileWorkspaceOwners(deps, ownerInputs, workspaceOwners) + } +} - val liveTabIds = tabs.map { it.id }.toSet() - workspaceOwners.keys - .filter { it !in liveTabIds } - .forEach { removedTabId -> - workspaceOwners.remove(removedTabId)?.close() - } +private data class TabOwnerInput( + val tabId: String, + val serverRef: ServerRef, + val workspaceKey: WorkspaceKey, + val connected: Boolean, + val generation: dev.blazelight.p4oc.domain.server.ServerGeneration?, +) - tabs.forEach { tab -> - val workspaceKey = tab.workspaceKey ?: return@forEach - val workspace = Workspace( - server = ServerRef.fromEndpoint(baseUrl), - directory = (workspaceKey as? WorkspaceKey.Directory)?.value, - ) - val currentOwner = workspaceOwners[tab.id] - if (currentOwner == null || - currentOwner.workspace != workspace || - currentOwner.generation != generation - ) { - // Acquire the new owner BEFORE releasing the old one. Repositories are - // shared and ref-counted per workspace key by SessionRepositoryProvider; - // constructing the new owner first bumps the shared repository's ref-count - // (transiently to 2 when the key is unchanged), so closing the old owner - // afterwards drops it back to 1 instead of to 0. Closing first would evict - // and close() a repository that other consumers (e.g. the session list, or - // another tab on the same directory) are still using, surfacing as - // "SessionRepository closed" and breaking submission until force-stop. - val newOwner = WorkspaceRepositoryOwner( - tabId = tab.id, - workspace = workspace, - generation = generation, - sessionRepositoryProvider = sessionRepositoryProvider, - ) - currentOwner?.close() - workspaceOwners[tab.id] = newOwner - } - } +private val reconcileWorkspaceOwners: ( + MainTabDeps, + List, + SnapshotStateMap, +) -> Unit = { deps, inputs, owners -> + val liveTabIds = inputs.map { it.tabId }.toSet() + owners.keys.filter { it !in liveTabIds }.forEach { id -> owners.remove(id)?.close() } + inputs.forEach { input -> reconcileWorkspaceOwner(deps, input, owners) } +} + +private fun reconcileWorkspaceOwner( + deps: MainTabDeps, + input: TabOwnerInput, + owners: SnapshotStateMap, +) { + val generation = input.generation + if (!input.connected || generation == null) { + owners.remove(input.tabId)?.close() + return } + val workspace = Workspace( + server = input.serverRef, + directory = (input.workspaceKey as? WorkspaceKey.Directory)?.value, + ) + val current = owners[input.tabId] + if (current?.workspace == workspace && current.generation == generation) return + val newOwner = WorkspaceRepositoryOwner( + tabId = input.tabId, + workspace = workspace, + generation = generation, + sessionRepositoryProvider = deps.sessionRepositoryProvider, + ) + current?.close() + owners[input.tabId] = newOwner +} - // Collect per-tab session presence outside page composition. HorizontalPager - // composes only the active page, but background chat/sub-agent tabs still need - // unread/busy updates when their repository state changes. +@Composable +private fun mainTabPresenceCollection( + tabs: List, + activeTabId: String?, + tabMaps: TabStateMaps, +) { tabs.forEach { tab -> val sessionId = tab.sessionId - val workspaceOwner = workspaceOwners[tab.id] + val workspaceOwner = tabMaps.workspaceOwners[tab.id] if (sessionId != null && workspaceOwner != null) { val sessionState by workspaceOwner.sessionRepository .sessionUiState(SessionId(sessionId)) .collectAsState() LaunchedEffect(tab.id, activeTabId, sessionState.responseCompletedToken) { if (tab.id == activeTabId) { - tabReadTokens[tab.id] = sessionState.responseCompletedToken + tabMaps.readTokens[tab.id] = sessionState.responseCompletedToken } } LaunchedEffect(tab.id, sessionState) { - val readToken = tabReadTokens[tab.id] ?: sessionState.responseCompletedToken - val hasUnread = sessionState.responseCompletedToken > readToken && sessionState.status !is SessionStatus.Busy - tabConnectionStates[tab.id] = sessionState.presence(hasUnread = hasUnread) + val readToken = tabMaps.readTokens[tab.id] ?: sessionState.responseCompletedToken + val hasUnread = sessionState.responseCompletedToken > readToken && + sessionState.status !is SessionStatus.Busy + tabMaps.connectionStates[tab.id] = sessionState.presence(hasUnread = hasUnread) } } else { val tabSessionState by tab.connectionState.collectAsState() LaunchedEffect(tab.id, tabSessionState) { - if (tabSessionState != null) { - tabConnectionStates[tab.id] = tabSessionState!! + val currentState = tabSessionState + if (currentState != null) { + tabMaps.connectionStates[tab.id] = currentState } else { - tabConnectionStates.remove(tab.id) - tabReadTokens.remove(tab.id) + tabMaps.connectionStates.remove(tab.id) + tabMaps.readTokens.remove(tab.id) } } } } +} - // Shared tab-close logic: PTY cleanup + state map cleanup + tabManager.closeTab. - // Used by both TabBar close button and TabNavHost BackHandler. - val closeTab: (String) -> Unit = remember(coroutineScope) { - { - tabId: String -> - coroutineScope.launch { - // Check if it's a terminal tab and delete the PTY - val route = tabRoutes[tabId] - if (route != null && route.startsWith("terminal/")) { - val ptyId = tabPtyIds[tabId] - if (ptyId != null) { - val api = connectionManager.getApi() - if (api != null) { - val result = safeApiCall { api.deletePtySession(ptyId) } - if (result is ApiResult.Error) { - AppLog.e(TAG, "Failed to delete PTY $ptyId: ${result.message}") - } +@Composable +private fun rememberCloseTab( + deps: MainTabDeps, + tabMaps: TabStateMaps, +): (String) -> Unit = remember(deps) { + fun(tabId: String) { + deps.coroutineScope.launch { + val route = tabMaps.routes[tabId] + if (route != null && route.startsWith("terminal/")) { + val ptyId = tabMaps.ptyIds[tabId] + if (ptyId != null) { + val serverRef = deps.tabManager.tabs.value + .firstOrNull { it.id == tabId } + ?.serverRef + val api = serverRef?.let(deps.serverConnectionRegistry::api) + if (api != null) { + val result = safeApiCall { api.deletePtySession(ptyId) } + if (result is ApiResult.Error) { + AppLog.e(TAG, "Failed to delete PTY $ptyId: ${result.message}") } } } - - // Clean up tracked state for this tab - tabRoutes.remove(tabId) - tabPtyIds.remove(tabId) - tabTitles.remove(tabId) - tabIcons.remove(tabId) - tabConnectionStates.remove(tabId) - - tabManager.closeTab(tabId) } + tabMaps.routes.remove(tabId) + tabMaps.ptyIds.remove(tabId) + tabMaps.connectionStates.remove(tabId) + deps.tabManager.closeTab(tabId) } } +} - // Snackbar for tab warning - val snackbarHostState = remember { SnackbarHostState() } - - fun requestFilesTab(target: StartWorkTarget) { - tabManager.focusOrCreateFilesTab( - serverRef = target.serverRef, - workspaceKey = target.workspaceKey, - ) +@Composable +private fun mainTabPendingStartWorkEffect( + deps: MainTabDeps, + uiState: StartWorkUiState, + scopedConnectionStates: Map, + snackbarHostState: SnackbarHostState, +) { + LaunchedEffect(uiState.pendingStartWork, scopedConnectionStates) { + val pending = uiState.pendingStartWork ?: return@LaunchedEffect + val target = pending.first + val action = pending.second + if (scopedConnectionStates[target.serverRef.endpointKey] !is ConnectionState.Connected) { + return@LaunchedEffect + } + val api = deps.serverConnectionRegistry.api(target.serverRef) ?: return@LaunchedEffect + when (action) { + StartWorkAction.NewChat -> { + val result = safeApiCall { + api.createSession( + directory = (target.workspaceKey as? WorkspaceKey.Directory)?.value, + request = CreateSessionRequest(), + ) + } + when (result) { + is ApiResult.Success -> { + uiState.pendingStartWork = null + deps.tabManager.createTab( + startRoute = Screen.Chat.createRoute(result.data.id), + workspaceKey = target.workspaceKey, + serverRef = target.serverRef, + focus = true, + ) + } + is ApiResult.Error -> snackbarHostState.showSnackbar(result.message) + } + } + StartWorkAction.Terminal -> { + val result = safeApiCall { + api.createPtySession(createPtyRequestForWorkspace(target.workspaceKey)) + } + when (result) { + is ApiResult.Success -> { + uiState.pendingStartWork = null + deps.tabManager.createTab( + startRoute = Screen.Terminal.createRoute(result.data.id), + workspaceKey = target.workspaceKey, + serverRef = target.serverRef, + focus = true, + ) + } + is ApiResult.Error -> snackbarHostState.showSnackbar(result.message) + } + } + else -> uiState.pendingStartWork = null + } } +} +@Composable +private fun mainTabSnackbarEffects( + deps: MainTabDeps, + showTabWarning: Boolean, + uiState: StartWorkUiState, + snackbarHostState: SnackbarHostState, +) { LaunchedEffect(showTabWarning) { if (showTabWarning) { snackbarHostState.showSnackbar( message = "Multiple tabs may affect performance", - duration = SnackbarDuration.Short + duration = SnackbarDuration.Short, ) - tabManager.dismissTabWarning() + deps.tabManager.dismissTabWarning() } } - - LaunchedEffect(restoreError) { - restoreError?.let { message -> + LaunchedEffect(uiState.restoreError) { + uiState.restoreError?.let { message -> snackbarHostState.showSnackbar(message, duration = SnackbarDuration.Long) - restoreError = null + uiState.restoreError = null } } +} +object MainTabScreen { + @OptIn(ExperimentalMaterial3Api::class) + @Composable + operator fun invoke( + onDisconnect: () -> Unit, + modifier: Modifier = Modifier, + ) { + val deps = rememberMainTabDeps() + val uiState = rememberStartWorkUiState() + val tabMaps = rememberTabStateMaps() + val lifecycleOwner = LocalLifecycleOwner.current + + LaunchedEffect(Unit) { deps.tabManager.ensureHomeTab(focus = false) } + mainTabForegroundEffect(deps.connectionManager, lifecycleOwner) + + val tabs by deps.tabManager.tabs.collectAsState() + val activeTabId by deps.tabManager.activeTabId.collectAsState() + val showTabWarning by deps.tabManager.showTabWarning.collectAsState() + val savedServers by deps.settingsDataStore.savedServers.collectAsState(initial = emptyList()) + val scopedConnectionStates = rememberScopedConnectionStates( + savedServers, + deps.serverConnectionRegistry, + ) + val savedServerViews = remember(savedServers) { + savedServers.map { SavedServerView(it.endpointKey, it.displayName, it.badgeLabel) } + } + val savedServerExists = rememberSavedServerExists(savedServers) + val connectSavedServer = rememberConnectSavedServer(deps.serverConnectionRegistry, savedServers) + + mainTabRestoreEffect(deps, savedServers, uiState) + savedServerConnectionEffect(deps.serverConnectionRegistry, savedServers) + mainTabPersistEffect(deps, tabs, activeTabId) + mainTabWorkspaceOwnersEffect( + deps, + tabs, + savedServers, + scopedConnectionStates, + tabMaps.workspaceOwners, + ) + mainTabPresenceCollection(tabs, activeTabId, tabMaps) + + val closeTab = rememberCloseTab(deps, tabMaps) + val snackbarHostState = remember { SnackbarHostState() } + mainTabPendingStartWorkEffect(deps, uiState, scopedConnectionStates, snackbarHostState) + mainTabSnackbarEffects(deps, showTabWarning, uiState, snackbarHostState) + + val params = MainTabContentParams( + deps = deps, + uiState = uiState, + tabMaps = tabMaps, + tabs = tabs, + activeTabId = activeTabId, + savedServers = savedServers, + savedServerViews = savedServerViews, + scopedConnectionStates = scopedConnectionStates, + closeTab = closeTab, + savedServerExists = savedServerExists, + connectSavedServer = connectSavedServer, + onDisconnect = onDisconnect, + ) + mainTabScaffold(params, snackbarHostState, modifier) + startWorkSheets(params) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun mainTabScaffold( + params: MainTabContentParams, + snackbarHostState: SnackbarHostState, + modifier: Modifier, +) { + val theme = LocalOpenCodeTheme.current + val tabTitleLabels = rememberTabTitleLabels() + val tabTitles = params.tabs.associate { it.id to getTitleForTab(it, tabTitleLabels) } + val tabIcons = params.tabs.associate { it.id to getIconForTab(it) } + val pagerState = rememberPagerState( + initialPage = params.tabs.indexOfFirst { it.id == params.activeTabId }.coerceAtLeast(0), + pageCount = { params.tabs.size }, + ) + LaunchedEffect(params.activeTabId, params.tabs.size) { + val index = params.tabs.indexOfFirst { it.id == params.activeTabId } + if (index >= 0 && pagerState.currentPage != index) { + pagerState.animateScrollToPage(index) + } + } + LaunchedEffect(pagerState.settledPage) { + params.tabs.getOrNull(pagerState.settledPage)?.let { tab -> + if (tab.id != params.activeTabId) params.deps.tabManager.focusTab(tab.id) + } + } Scaffold( snackbarHost = { SnackbarHost(snackbarHostState) }, containerColor = theme.background, contentWindowInsets = WindowInsets(0), - modifier = modifier + modifier = modifier, ) { innerPadding -> - // We consume the status bar insets here so child Scaffolds don't double-pad. - // The tab bar gets the status bar padding, then consumeWindowInsets tells - // downstream composables that the status bar is already accounted for. Column( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding) - .statusBarsPadding() - .consumeWindowInsets(WindowInsets.statusBars) + Modifier.fillMaxSize().padding(innerPadding) + .statusBarsPadding().consumeWindowInsets(WindowInsets.statusBars), ) { - // Tab bar (no longer needs its own statusBarsPadding). - // Top-level plus actions inherit the active tab target when possible. - Box(modifier = Modifier.fillMaxWidth()) { - TabBar( - tabs = tabs, - activeTabId = activeTabId, - tabTitles = tabTitles, - tabIcons = tabIcons, - tabConnectionStates = tabConnectionStates, - onTabClick = { tabId -> - tabManager.focusTab(tabId) - }, - onTabClose = closeTab, - onAddClick = { - showStartWorkSheet = true - }, - ) - } - - // Pager state for swipe between tabs - val pagerState = rememberPagerState( - initialPage = tabs.indexOfFirst { it.id == activeTabId }.coerceAtLeast(0), - pageCount = { tabs.size } + TabBar( + tabs = params.tabs, + activeTabId = params.activeTabId, + tabTitles = tabTitles, + tabIcons = tabIcons, + tabConnectionStates = params.tabMaps.connectionStates, + onTabClick = { id -> params.deps.tabManager.focusTab(id) }, + onTabClose = params.closeTab, + onAddClick = { + if (params.deps.tabManager.activeTab?.isPinnedHome == true) { + params.uiState.showStartWorkPicker = true + } else { + params.uiState.startWorkContext = startWorkContextFor(params.deps.tabManager.activeTab) + params.uiState.showStartWorkSheet = true + } + }, ) + mainTabPager(params, pagerState) + } + } +} - // Sync activeTabId -> pager (when tab clicked or closed) - LaunchedEffect(activeTabId, tabs.size) { - val index = tabs.indexOfFirst { it.id == activeTabId } - if (index >= 0 && pagerState.currentPage != index) { - pagerState.animateScrollToPage(index) - } +@Composable +private fun ColumnScope.mainTabPager( + params: MainTabContentParams, + pagerState: PagerState, +) { + val saveableStateHolder = rememberSaveableStateHolder() + val homeRepositoryStates = params.tabMaps.workspaceOwners.values + .distinctBy { it.workspace.server.endpointKey to it.workspace.key } + .map { owner -> + val state by owner.sessionRepository.state.collectAsState() + ScopedHomeRepositoryState(owner.workspace.server, state) + } + HorizontalPager( + state = pagerState, + modifier = Modifier.weight(1f), + key = { params.tabs.getOrNull(it)?.id ?: it.toString() }, + beyondViewportPageCount = 0, + ) { pageIndex -> + params.tabs.getOrNull(pageIndex)?.let { tab -> + saveableStateHolder.SaveableStateProvider(tab.id) { + mainTabPageContent(params, tab, homeRepositoryStates) } + } + } +} - // Sync pager -> activeTabId (when user swipes) - LaunchedEffect(pagerState.settledPage) { - tabs.getOrNull(pagerState.settledPage)?.let { tab -> - if (tab.id != activeTabId) { - tabManager.focusTab(tab.id) - } +@Composable +private fun mainTabPageContent( + params: MainTabContentParams, + tab: TabInstance, + homeRepositoryStates: List, +) { + val navController = rememberNavController() + val backStackEntry by navController.currentBackStackEntryAsState() + LaunchedEffect(backStackEntry) { + val ptyId = backStackEntry?.arguments?.getString(Screen.Terminal.ARG_PTY_ID) + if (ptyId != null) { + params.tabMaps.ptyIds[tab.id] = ptyId + params.tabMaps.routes[tab.id] = Screen.Terminal.createRoute(ptyId) + } + } + val isActive = tab.id == params.activeTabId + val workspaceOwner = params.tabMaps.workspaceOwners[tab.id] + if (tab.isPinnedHome) { + mainTabHomeContent(params, homeRepositoryStates) + } else if (workspaceOwner != null) { + mainTabTabNavHostContent(params, tab, navController, isActive, workspaceOwner) + } else { + mainTabEmptyContent(params, tab) + } +} + +@Composable +private fun mainTabHomeContent( + params: MainTabContentParams, + homeRepositoryStates: List, +) { + homeScreen( + summary = HomeSummaryBuilder.build( + HomeSummaryInput( + savedServers = params.savedServers, + connectionStates = params.scopedConnectionStates, + tabs = params.tabs, + repositories = homeRepositoryStates, + ), + ), + actions = HomeActions( + onBrowseSessions = { target -> + requestScopedAction(params, target, StartWorkAction.BrowseSessions) + }, + onBrowseAllSessions = { params.uiState.showFilesTabPrompt = true }, + onManageServers = params.onDisconnect, + onFocusTab = params.deps.tabManager::focusTab, + onResumeSession = { session -> + val existing = params.deps.tabManager.findTabBySessionId(session.sessionId.value) + if (existing != null) { + params.deps.tabManager.focusTab(existing.id) + } else { + params.deps.tabManager.createTab( + startRoute = Screen.Chat.createRoute(session.sessionId.value), + workspaceKey = session.workspaceKey, + serverRef = session.serverRef, + focus = true, + ) } - } + }, + onStartScopedWork = { target -> + params.uiState.homeDetailSelection = StartWorkSelection.Selected(target) + params.uiState.startWorkContext = startWorkContextForHomeDetail(target) + params.uiState.showStartWorkSheet = true + }, + onOpenFiles = { target -> requestScopedAction(params, target, StartWorkAction.Files) }, + onOpenTerminal = { target -> + requestScopedAction(params, target, StartWorkAction.Terminal) + }, + onChooseTarget = { params.uiState.showFilesTabPrompt = true }, + onWorkspaceDetailChanged = { params.uiState.homeDetailSelection = it }, + ), + modifier = Modifier.fillMaxSize(), + ) +} - // Tab content area with HorizontalPager for swipe between tabs - val saveableStateHolder = rememberSaveableStateHolder() +@Composable +private fun mainTabTabNavHostContent( + params: MainTabContentParams, + tab: TabInstance, + navController: NavHostController, + isActive: Boolean, + workspaceOwner: WorkspaceRepositoryOwner, +) { + val serverRef = tab.serverRef ?: return + TabNavHost( + navController = navController, + tabManager = params.deps.tabManager, + tabId = tab.id, + serverRef = serverRef, + onDisconnect = params.onDisconnect, + onCloseTab = { params.closeTab(tab.id) }, + startRoute = tab.startRoute, + workspaceOwner = workspaceOwner, + onNewFilesTab = { + val sr = tab.serverRef + val wk = tab.workspaceKey + if (sr != null && wk != null) { + requestScopedAction(params, StartWorkTarget(sr, wk), StartWorkAction.Files) + } else { + params.uiState.showStartWorkPicker = true + } + }, + onNewTerminalTab = { + val sr = tab.serverRef + val wk = tab.workspaceKey + if (sr != null && wk != null) { + requestScopedAction(params, StartWorkTarget(sr, wk), StartWorkAction.Terminal) + } else { + params.uiState.showStartWorkPicker = true + } + }, + isActiveTab = isActive, + onConnectionStateChanged = { state -> tab.updateConnectionState(state) }, + modifier = Modifier.fillMaxSize(), + ) +} - HorizontalPager( - state = pagerState, - modifier = Modifier.weight(1f), - key = { tabs.getOrNull(it)?.id ?: it.toString() }, - beyondViewportPageCount = 0 - ) { pageIndex -> - tabs.getOrNull(pageIndex)?.let { tab -> - saveableStateHolder.SaveableStateProvider(tab.id) { - val navController = rememberNavController() - - // Track route for title/icon - val backStackEntry by navController.currentBackStackEntryAsState() - LaunchedEffect(backStackEntry?.destination?.route, tab.sessionTitle) { - val route = backStackEntry?.destination?.route - // Only update when route is resolved — avoids overwriting - // seeded values with "Tab" during initial null-route composition - if (route != null) { - tabTitles[tab.id] = getTitleForRoute( - route = route, - labels = tabTitleLabels, - sessionTitle = tab.sessionTitle, - workspaceKey = tab.workspaceKey, - ) - tabIcons[tab.id] = getIconForRoute(route) - } - // Track PTY ID if on a terminal route - val ptyId = backStackEntry?.arguments?.getString(Screen.Terminal.ARG_PTY_ID) - if (ptyId != null) { - tabPtyIds[tab.id] = ptyId - tabRoutes[tab.id] = Screen.Terminal.createRoute(ptyId) - } - } +private val mainTabEmptyContent: @Composable (MainTabContentParams, TabInstance) -> Unit = { params, tab -> + val theme = LocalOpenCodeTheme.current + Box(modifier = Modifier.fillMaxSize()) { + if (tab.serverRef == null || + params.scopedConnectionStates[tab.serverRef?.endpointKey] !is ConnectionState.Connected + ) { + Text( + text = "Not connected to server", + color = theme.textMuted, + modifier = Modifier.align(Alignment.Center), + ) + } + } +} - val isActive = tab.id == activeTabId - val workspaceOwner = workspaceOwners[tab.id] - if (tab.isPinnedHome) { - homeScreen( - summary = HomeSummaryBuilder.build( - savedServers = savedServers, - connectionStates = homeConnectionStates, - tabs = tabs, - ), - actions = HomeActions( - onBrowseSessions = { target -> - tabManager.createTab( - startRoute = Screen.Sessions.route, - workspaceKey = target.workspaceKey, - serverRef = target.serverRef, - focus = true, - ) - }, - onOpenFiles = { target -> requestFilesTab(target) }, - onOpenTerminal = { target -> - coroutineScope.launch { - if (target.serverRef.endpointKey != currentServerRef?.endpointKey) { - snackbarHostState.showSnackbar( - "Select and connect to ${target.serverRef.displayName} first", - ) - return@launch - } - val api = connectionManager.getApi() ?: run { - snackbarHostState.showSnackbar("Not connected to server") - return@launch - } - val result = safeApiCall { - api.createPtySession( - createPtyRequestForWorkspace(target.workspaceKey), - ) - } - if (result is ApiResult.Success) { - tabManager.createTab( - startRoute = Screen.Terminal.createRoute(result.data.id), - workspaceKey = target.workspaceKey, - serverRef = target.serverRef, - focus = true, - ) - } else if (result is ApiResult.Error) { - snackbarHostState.showSnackbar( - "Failed to create terminal: ${result.message}", - ) - } - } - }, - onChooseTarget = { showFilesTabPrompt = true }, - onWorkspaceDetailChanged = { homeDetailSelection = it }, - ), - modifier = Modifier.fillMaxSize(), - ) - } else if (workspaceOwner != null) { - TabNavHost( - navController = navController, - tabManager = tabManager, - tabId = tab.id, - serverRef = tab.serverRef ?: return@SaveableStateProvider, - onDisconnect = onDisconnect, - onCloseTab = { closeTab(tab.id) }, - startRoute = tab.startRoute, - workspaceOwner = workspaceOwner, - onNewFilesTab = { - val serverRef = tab.serverRef - val workspaceKey = tab.workspaceKey - if (serverRef != null && workspaceKey != null) { - requestFilesTab(StartWorkTarget(serverRef, workspaceKey)) - } else { - showFilesTabPrompt = true - } - }, - onNewTerminalTab = { - coroutineScope.launch { - val api = connectionManager.getApi() ?: run { - AppLog.e(TAG, "Cannot create terminal: not connected") - snackbarHostState.showSnackbar("Not connected to server") - return@launch - } - val workspaceKey = tab.workspaceKey ?: run { - AppLog.e(TAG, "Cannot create terminal: tab has no workspace identity") - snackbarHostState.showSnackbar( - "Cannot create terminal: workspace is unavailable" - ) - return@launch - } - val result = safeApiCall { - api.createPtySession(createPtyRequestForWorkspace(workspaceKey)) - } - when (result) { - is ApiResult.Success -> { - val ptyId = result.data.id - tabManager.createTab( - startRoute = Screen.Terminal.createRoute(ptyId), - workspaceKey = workspaceKey, - serverRef = tab.serverRef ?: return@launch, - focus = true, - ) - } - is ApiResult.Error -> { - AppLog.e(TAG, "Failed to create PTY: ${result.message}") - snackbarHostState.showSnackbar( - "Failed to create terminal: ${result.message}" - ) - } - } - } - }, - isActiveTab = isActive, - onConnectionStateChanged = { state -> - tab.updateConnectionState(state) - }, - modifier = Modifier.fillMaxSize() - ) - } else { - Box(modifier = Modifier.fillMaxSize()) { - if (baseUrl == null || generation == null) { - Text( - text = "Not connected to server", - color = theme.textMuted, - modifier = Modifier.align(Alignment.Center), - ) - } - } - } - } - } +private fun requestScopedAction( + params: MainTabContentParams, + target: StartWorkTarget, + action: StartWorkAction, +) { + val deps = params.deps + val uiState = params.uiState + if (!params.savedServerExists(target.serverRef.endpointKey)) { + uiState.pendingStartWork = target to action + uiState.startWorkContext = StartWorkContext( + source = StartWorkSource.OtherTab, + selection = StartWorkSelection.Selected(target), + defaultAction = action, + ) + return + } + when (action) { + StartWorkAction.Files -> deps.tabManager.focusOrCreateFilesTab( + serverRef = target.serverRef, + workspaceKey = target.workspaceKey, + ) + StartWorkAction.BrowseSessions -> deps.tabManager.createTab( + startRoute = Screen.Sessions.route, + workspaceKey = target.workspaceKey, + serverRef = target.serverRef, + focus = true, + ) + StartWorkAction.NewChat, StartWorkAction.Terminal -> { + uiState.pendingStartWork = target to action + if (params.scopedConnectionStates[target.serverRef.endpointKey] !is ConnectionState.Connected) { + params.connectSavedServer(target.serverRef.endpointKey) } } + StartWorkAction.ChooseAnotherTarget -> uiState.showStartWorkPicker = true } +} - if (showStartWorkSheet) { - val availableServers = savedServers.map { - ServerRef.fromEndpointKey(it.endpointKey, it.displayName) +private val startWorkSheets: @Composable (MainTabContentParams) -> Unit = { params -> + val uiState = params.uiState + if (uiState.showStartWorkSheet) { + startWorkSheet(params) + } + if (uiState.showStartWorkPicker || uiState.showFilesTabPrompt) { + startWorkPickerSheet(params) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun startWorkSheet(params: MainTabContentParams) { + val uiState = params.uiState + val theme = LocalOpenCodeTheme.current + val context = uiState.startWorkContext + ?: if (params.deps.tabManager.activeTab?.isPinnedHome == true) { + StartWorkContext(StartWorkSource.HomeWorkspaceDetail, uiState.homeDetailSelection) + } else { + startWorkContextFor(params.deps.tabManager.activeTab) } - val rawContext = if (tabManager.activeTab?.isPinnedHome == true) { - StartWorkContext( - source = StartWorkSource.HomeWorkspaceDetail, - selection = homeDetailSelection, - ) + val target = context.selectedTarget + ModalBottomSheet( + onDismissRequest = { uiState.showStartWorkSheet = false }, + containerColor = theme.background, + modifier = Modifier.testTag("start_work_sheet"), + ) { + startWorkSheetContent(params, target) + } +} + +@Composable +private fun startWorkSheetContent( + params: MainTabContentParams, + target: StartWorkTarget?, +) { + val theme = LocalOpenCodeTheme.current + Column( + Modifier.fillMaxWidth().padding(Spacing.md), + verticalArrangement = Arrangement.spacedBy(Spacing.sm), + ) { + Text(stringResource(R.string.start_work_title), style = MaterialTheme.typography.titleLarge) + if (target == null) { + Text(stringResource(R.string.start_work_choose_context), color = theme.textMuted) + LaunchedEffect(Unit) { params.uiState.showStartWorkPicker = true } } else { - startWorkContextFor(tabManager.activeTab) + startWorkSheetTargetCard(params, target) + startWorkSheetScopedActions(params, target) + Text(stringResource(R.string.start_work_existing_work), color = theme.textMuted) + startWorkActionRow( + label = stringResource(R.string.start_work_sessions), + description = stringResource(R.string.start_work_sessions_description), + marker = "S", + ) { + params.uiState.showStartWorkSheet = false + requestScopedAction(params, target, StartWorkAction.BrowseSessions) + } } - val startContext = rawContext.copy( - selection = rawContext.selection.validatedAgainst(availableServers), - ) - val target = startContext.selectedTarget - TuiAlertDialog( - onDismissRequest = { showStartWorkSheet = false }, - title = "Start work", - confirmButton = { - TuiTextButton(onClick = { showStartWorkSheet = false }) { - Text("Cancel") + Spacer(Modifier.navigationBarsPadding()) + } +} + +@Composable +private fun startWorkSheetTargetCard( + params: MainTabContentParams, + target: StartWorkTarget, +) { + val theme = LocalOpenCodeTheme.current + val tabTitleLabels = rememberTabTitleLabels() + Surface( + color = theme.backgroundElement, + shape = TuiShapes.small, + modifier = Modifier.fillMaxWidth().testTag("start_work_context"), + ) { + Column(Modifier.padding(Spacing.md)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(R.string.start_work_in), color = theme.textMuted) + Spacer(Modifier.width(Spacing.sm)) + Text(target.serverRef.displayName, modifier = Modifier.weight(1f)) + TextButton(onClick = { params.uiState.showStartWorkPicker = true }) { + Text(stringResource(R.string.start_work_change)) } - }, - ) { - Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm)) { - Text( - text = target?.let { - val workspace = workspaceLabel(it.workspaceKey, tabTitleLabels) - ?: workspaceSubtitle(it.workspaceKey) - "Target: ${it.serverRef.displayName} · $workspace" - } ?: "Choose a target before creating work.", - color = theme.textMuted, - style = MaterialTheme.typography.bodySmall, - ) - StartWorkActionRow( - label = "New chat", - description = "Start a chat in the target workspace.", - marker = "C", - onClick = { - showStartWorkSheet = false - if (target == null) { - showFilesTabPrompt = true - } else { - tabManager.createTab( - startRoute = Screen.Sessions.route, - workspaceKey = target.workspaceKey, - serverRef = target.serverRef, - focus = true, - ) - } - }, - ) - StartWorkActionRow( - label = "Browse sessions", - description = "Browse sessions for the exact target workspace.", - marker = "S", - onClick = { - showStartWorkSheet = false - if (target == null) { - showFilesTabPrompt = true - } else { - tabManager.createTab( - startRoute = Screen.Sessions.route, - workspaceKey = target.workspaceKey, - serverRef = target.serverRef, - focus = true, - ) - } - }, - ) - StartWorkActionRow( - label = "Files tab", - description = "Open files for the target workspace.", - marker = "F", - onClick = { - showStartWorkSheet = false - if (target == null) showFilesTabPrompt = true else requestFilesTab(target) - }, - ) - StartWorkActionRow( - label = "Terminal", - description = "Create a terminal for the target workspace.", - marker = "T", - onClick = { - showStartWorkSheet = false - if (target == null) { - showFilesTabPrompt = true - } else { - coroutineScope.launch { - if (target.serverRef.endpointKey != currentServerRef?.endpointKey) { - snackbarHostState.showSnackbar( - "Select and connect to ${target.serverRef.displayName} first", - ) - return@launch - } - val api = connectionManager.getApi() ?: run { - snackbarHostState.showSnackbar("Not connected to server") - return@launch - } - val result = safeApiCall { - api.createPtySession(createPtyRequestForWorkspace(target.workspaceKey)) - } - if (result is ApiResult.Success) { - tabManager.createTab( - startRoute = Screen.Terminal.createRoute(result.data.id), - workspaceKey = target.workspaceKey, - serverRef = target.serverRef, - focus = true, - ) - } else if (result is ApiResult.Error) { - snackbarHostState.showSnackbar("Failed to create terminal: ${result.message}") - } - } - } - }, - ) - StartWorkActionRow( - label = "Choose another target", - description = "Pick a different workspace target before creating work.", - marker = "…", - onClick = { - showStartWorkSheet = false - showFilesTabPrompt = true - }, - ) } + Text( + workspaceLabel(target.workspaceKey, tabTitleLabels) + ?: workspaceSubtitle(target.workspaceKey), + ) + Text( + connectionStatusText(params.scopedConnectionStates[target.serverRef.endpointKey]), + color = theme.textMuted, + ) } } +} + +private val connectionStatusText: @Composable (ConnectionState?) -> String = { state -> + when (state) { + is ConnectionState.Connected -> stringResource(R.string.server_status_connected) + is ConnectionState.Connecting -> stringResource(R.string.server_status_connecting) + is ConnectionState.Error -> stringResource(R.string.server_status_error) + else -> stringResource(R.string.server_status_offline) + } +} - if (showFilesTabPrompt) { - val availableEndpointKeys = savedServers.mapTo(mutableSetOf()) { it.endpointKey } - val openTargets = tabs.mapNotNull { tab -> - val serverRef = tab.serverRef ?: return@mapNotNull null - val workspaceKey = tab.workspaceKey ?: return@mapNotNull null - StartWorkTarget(serverRef, workspaceKey) - }.filter { it.serverRef.endpointKey in availableEndpointKeys } - .distinct() - val noProjectTargets = savedServers.map { - StartWorkTarget( - ServerRef.fromEndpointKey(it.endpointKey, it.displayName), - WorkspaceKey.Global, - ) +@Composable +private fun startWorkSheetScopedActions( + params: MainTabContentParams, + target: StartWorkTarget, +) { + val labels = mapOf( + StartWorkAction.NewChat to (R.string.start_work_new_chat to "C"), + StartWorkAction.Files to (R.string.start_work_files to "F"), + StartWorkAction.Terminal to (R.string.start_work_terminal to "T"), + ) + startWorkScopedActionOrder.forEach { action -> + val (label, marker) = checkNotNull(labels[action]) + startWorkActionRow( + label = stringResource(label), + description = stringResource(R.string.start_work_scoped_action), + marker = marker, + ) { + params.uiState.showStartWorkSheet = false + requestScopedAction(params, target, action) } - fun selectTarget(target: StartWorkTarget) { - requestFilesTab(target) - showFilesTabPrompt = false + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun startWorkPickerSheet(params: MainTabContentParams) { + val uiState = params.uiState + val theme = LocalOpenCodeTheme.current + val openTargets = params.tabs.mapNotNull { tab -> + val serverRef = tab.serverRef ?: return@mapNotNull null + val workspaceKey = tab.workspaceKey ?: return@mapNotNull null + StartWorkTarget(serverRef, workspaceKey) + }.distinct() + val knownHomeTargets = params.tabMaps.workspaceOwners.values.flatMap { owner -> + val state by owner.sessionRepository.state.collectAsState() + state.snapshot.sessions.values.map { session -> + StartWorkTarget(owner.workspace.server, session.workspace.key) } + }.distinct() + ModalBottomSheet( + onDismissRequest = { + uiState.showStartWorkPicker = false + uiState.showFilesTabPrompt = false + }, + containerColor = theme.background, + modifier = Modifier.testTag("start_work_context_picker"), + ) { + startWorkPickerContent(params, openTargets, knownHomeTargets) + } +} - TuiAlertDialog( - onDismissRequest = { showFilesTabPrompt = false }, - title = "Select workspace", - confirmButton = { - TuiTextButton(onClick = { showFilesTabPrompt = false }) { - Text("Cancel") - } - } - ) { - Text("Choose an exact server and workspace:") - noProjectTargets.forEach { target -> - FilesWorkspaceOption( - title = "${target.serverRef.displayName} · No project context", - subtitle = "Explicit server scope", - marker = "◆", - onClick = { selectTarget(target) }, - ) +@Composable +private fun startWorkPickerContent( + params: MainTabContentParams, + openTargets: List, + knownHomeTargets: List, +) { + val theme = LocalOpenCodeTheme.current + val tabTitleLabels = rememberTabTitleLabels() + Column( + Modifier.fillMaxWidth().padding(Spacing.md), + verticalArrangement = Arrangement.spacedBy(Spacing.sm), + ) { + Text( + stringResource(R.string.start_work_choose_context), + style = MaterialTheme.typography.titleLarge, + ) + if (params.savedServerViews.isEmpty()) { + Text(stringResource(R.string.start_work_no_servers), color = theme.textMuted) + } + val groups = buildStartWorkPickerGroups( + params.savedServerViews.map { Triple(it.endpointKey, it.displayName, it.badgeLabel) }, + openTargets, + knownHomeTargets, + ) + groups.forEach { group -> startWorkPickerGroup(params, group, tabTitleLabels) } + Spacer(Modifier.navigationBarsPadding()) + } +} + +private val startWorkPickerGroup: @Composable ( + MainTabContentParams, + StartWorkPickerGroup, + TabTitleLabels, +) -> Unit = { params, group, tabTitleLabels -> + val collapsed = group.server.endpointKey in params.uiState.collapsedPickerServers + Text( + "${if (collapsed) "▸" else "▾"} ${group.badgeLabel} ${group.server.displayName}", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.fillMaxWidth().clickable(role = Role.Button) { + params.uiState.collapsedPickerServers = if (collapsed) { + params.uiState.collapsedPickerServers - group.server.endpointKey + } else { + params.uiState.collapsedPickerServers + group.server.endpointKey } - openTargets.filter { it.workspaceKey != WorkspaceKey.Global }.forEach { target -> - FilesWorkspaceOption( - title = workspaceLabel(target.workspaceKey, tabTitleLabels) ?: "Missing workspace", - subtitle = "${target.serverRef.displayName} · ${workspaceSubtitle(target.workspaceKey)}", - marker = "◇", - onClick = { selectTarget(target) }, - ) + }.semantics { + contentDescription = "${group.server.displayName}, ${if (collapsed) "collapsed" else "expanded"}" + }.testTag("start_work_server_${group.server.endpointKey}"), + ) + if (!collapsed) { + group.targets.forEach { pickedTarget -> + val title = if (pickedTarget.workspaceKey == WorkspaceKey.Global) { + stringResource(R.string.sessions_global) + } else { + workspaceLabel(pickedTarget.workspaceKey, tabTitleLabels) + ?: workspaceSubtitle(pickedTarget.workspaceKey) } + filesWorkspaceOption( + title = title, + subtitle = pickedTarget.serverRef.displayName, + marker = if (pickedTarget.workspaceKey == WorkspaceKey.Global) "◆" else "◇", + onClick = { + params.uiState.startWorkContext = StartWorkContext( + StartWorkSource.OtherTab, + StartWorkSelection.Selected(pickedTarget), + params.uiState.startWorkContext?.defaultAction, + ) + params.uiState.homeDetailSelection = StartWorkSelection.Selected(pickedTarget) + params.uiState.showStartWorkPicker = false + params.uiState.showFilesTabPrompt = false + params.uiState.showStartWorkSheet = true + }, + ) } } } -internal fun createPtyRequestForWorkspace(workspaceKey: WorkspaceKey): CreatePtyRequest = CreatePtyRequest( - cwd = (workspaceKey as? WorkspaceKey.Directory)?.value, - title = terminalTitle(workspaceKey), -) -private fun terminalTitle(workspaceKey: WorkspaceKey): String? = when (workspaceKey) { - is WorkspaceKey.Directory -> workspaceKey.value.trimEnd('/').substringAfterLast('/').ifBlank { "Terminal" } - WorkspaceKey.Global -> null - is WorkspaceKey.SessionScoped -> workspaceKey.sessionId.value +internal val createPtyRequestForWorkspace: (WorkspaceKey) -> CreatePtyRequest = { workspaceKey -> + CreatePtyRequest( + cwd = (workspaceKey as? WorkspaceKey.Directory)?.value, + title = terminalTitle(workspaceKey), + ) +} + +private val terminalTitle: (WorkspaceKey) -> String? = { workspaceKey -> + when (workspaceKey) { + is WorkspaceKey.Directory -> workspaceKey.value.trimEnd('/').substringAfterLast('/').ifBlank { "Terminal" } + WorkspaceKey.Global -> null + is WorkspaceKey.SessionScoped -> workspaceKey.sessionId.value + } } -private fun workspaceSubtitle(workspaceKey: WorkspaceKey): String = when (workspaceKey) { - is WorkspaceKey.Directory -> workspaceKey.value - WorkspaceKey.Global -> "No project context" - is WorkspaceKey.SessionScoped -> "Session-scoped workspace" +private val workspaceSubtitle: (WorkspaceKey) -> String = { workspaceKey -> + when (workspaceKey) { + is WorkspaceKey.Directory -> workspaceKey.value + WorkspaceKey.Global -> "No project context" + is WorkspaceKey.SessionScoped -> "Session-scoped workspace" + } } @Composable -private fun StartWorkActionRow( +private fun startWorkActionRow( label: String, description: String, marker: String, onClick: () -> Unit, ) { - FilesWorkspaceOption( + filesWorkspaceOption( title = label, subtitle = description, marker = marker, onClick = onClick, + modifier = Modifier + .testTag("start_work_${marker.lowercase()}") + .semantics { contentDescription = "$label. $description" }, ) } @Composable -private fun FilesWorkspaceOption( +private fun filesWorkspaceOption( title: String, subtitle: String, marker: String, @@ -767,7 +1134,6 @@ private fun FilesWorkspaceOption( modifier: Modifier = Modifier, ) { val theme = LocalOpenCodeTheme.current - Surface( modifier = modifier .fillMaxWidth() diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/StartWorkContext.kt b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/StartWorkContext.kt index d8c4d4c7..77ae084d 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/StartWorkContext.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/StartWorkContext.kt @@ -13,6 +13,28 @@ sealed interface StartWorkSelection { data object NeedsSelection : StartWorkSelection } +enum class StartWorkConnectionState { + Online, + Offline, + AuthRequired, +} + +enum class StartWorkAvailability { + Ready, + NoServers, + ServerRemoved, + WorkspaceMissing, + Offline, + AuthRequired, +} + +data class StartWorkResolvedContext( + val context: StartWorkContext, + val target: StartWorkTarget?, + val pendingAction: StartWorkAction?, + val availability: StartWorkAvailability, +) + data class StartWorkContext( val source: StartWorkSource, val selection: StartWorkSelection, @@ -21,15 +43,32 @@ data class StartWorkContext( val selectedTarget: StartWorkTarget? get() = (selection as? StartWorkSelection.Selected)?.target } -fun StartWorkSelection.validatedAgainst(availableServers: Collection): StartWorkSelection = when (this) { - StartWorkSelection.NeedsSelection -> this - is StartWorkSelection.Selected -> if ( - availableServers.any { it.endpointKey == target.serverRef.endpointKey } - ) { - this - } else { - StartWorkSelection.NeedsSelection + +fun StartWorkContext.resolve( + availableServers: Collection, + availableWorkspaces: Collection, + connectionStates: Map, +): StartWorkResolvedContext { + val target = selectedTarget + val availability = when { + availableServers.isEmpty() -> StartWorkAvailability.NoServers + target == null -> StartWorkAvailability.Ready + availableServers.none { it.endpointKey == target.serverRef.endpointKey } -> + StartWorkAvailability.ServerRemoved + target.workspaceKey != WorkspaceKey.Global && target !in availableWorkspaces -> + StartWorkAvailability.WorkspaceMissing + connectionStates[target.serverRef.endpointKey] == StartWorkConnectionState.AuthRequired -> + StartWorkAvailability.AuthRequired + connectionStates[target.serverRef.endpointKey] != StartWorkConnectionState.Online -> + StartWorkAvailability.Offline + else -> StartWorkAvailability.Ready } + return StartWorkResolvedContext( + context = this, + target = target, + pendingAction = defaultAction, + availability = availability, + ) } enum class StartWorkSource { diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabBar.kt b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabBar.kt index bf27090b..be914dda 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabBar.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabBar.kt @@ -13,11 +13,12 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import dev.blazelight.p4oc.R import dev.blazelight.p4oc.domain.model.SessionConnectionState @@ -32,6 +33,18 @@ import dev.blazelight.p4oc.ui.theme.Spacing * Tab bar showing all open tabs with indicators and close buttons. * Reuses visual language from SessionStatusBar. */ +private data class TabIndicatorState( + val title: String, + val icon: ImageVector, + val serverBadge: String?, + val accessibilityLabel: String, + val connectionState: SessionConnectionState?, + val isActive: Boolean, + val closeable: Boolean = true, + val onClick: () -> Unit, + val onClose: () -> Unit, +) + @Composable fun TabBar( tabs: List, @@ -47,9 +60,9 @@ fun TabBar( val theme = LocalOpenCodeTheme.current val listState = rememberLazyListState() - // Auto-scroll to active tab when it changes - LaunchedEffect(activeTabId) { - val activeIndex = tabs.indexOfFirst { it.id == activeTabId } + // Home is pinned outside the scrolling work-tab list. + LaunchedEffect(activeTabId, tabs) { + val activeIndex = tabs.filterNot { it.isPinnedHome }.indexOfFirst { it.id == activeTabId } if (activeIndex >= 0) { listState.animateScrollToItem(activeIndex) } @@ -67,29 +80,66 @@ fun TabBar( .padding(horizontal = Spacing.xs), verticalAlignment = Alignment.CenterVertically ) { + tabs.firstOrNull { it.isPinnedHome }?.let { home -> + tabIndicator( + state = TabIndicatorState( + title = tabTitles.getValue(home.id), + icon = tabIcons.getValue(home.id), + serverBadge = null, + accessibilityLabel = tabTitles.getValue(home.id), + connectionState = null, + isActive = home.id == activeTabId, + closeable = false, + onClick = { onTabClick(home.id) }, + onClose = {}, + ), + modifier = Modifier.testTag("tab_home"), + ) + } + LazyRow( state = listState, modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(Spacing.xs), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { items( - items = tabs, - key = { it.id } + items = tabs.filterNot { it.isPinnedHome }, + key = { it.id }, ) { tab -> - val isActive = tab.id == activeTabId - val title = tabTitles[tab.id] ?: "Tab" - val icon = tabIcons[tab.id] ?: Icons.Default.Tab - val connectionState = tabConnectionStates[tab.id] + val title = tabTitles.getValue(tab.id) + val workspaceIdentity = when (val key = tab.workspaceKey) { + is WorkspaceKey.Directory -> key.value + WorkspaceKey.Global -> stringResource(R.string.tab_workspace_global) + is WorkspaceKey.SessionScoped -> stringResource( + R.string.tab_accessibility_session_context, + key.sessionId.value, + ) + null -> null + } + val serverIdentity = tab.serverRef?.displayName + val accessibilityLabel = when { + serverIdentity != null && workspaceIdentity != null -> stringResource( + R.string.tab_accessibility_identity, + serverIdentity, + workspaceIdentity, + title, + ) + else -> listOfNotNull(serverIdentity, workspaceIdentity, title).joinToString(", ") + } - TabIndicator( - title = title, - icon = icon, - connectionState = connectionState, - isActive = isActive, - closeable = !tab.isPinnedHome, - onClick = { onTabClick(tab.id) }, - onClose = { onTabClose(tab.id) } + tabIndicator( + state = TabIndicatorState( + title = title, + icon = tabIcons.getValue(tab.id), + serverBadge = tab.serverRef?.badgeLabel, + accessibilityLabel = accessibilityLabel, + connectionState = tabConnectionStates[tab.id], + isActive = tab.id == activeTabId, + onClick = { onTabClick(tab.id) }, + onClose = { onTabClose(tab.id) }, + ), + modifier = Modifier.testTag("work_tab_${tab.id}"), ) } } @@ -101,7 +151,7 @@ fun TabBar( ) { Icon( imageVector = Icons.Default.Add, - contentDescription = "New tab", + contentDescription = stringResource(R.string.cd_new_work), modifier = Modifier.size(Sizing.iconSm), tint = theme.textMuted ) @@ -114,81 +164,80 @@ fun TabBar( * Individual tab indicator showing icon, title, state, and close button. */ @Composable -private fun TabIndicator( - title: String, - icon: ImageVector, - connectionState: SessionConnectionState?, - isActive: Boolean, - closeable: Boolean = true, - onClick: () -> Unit, - onClose: () -> Unit, - modifier: Modifier = Modifier +private fun tabIndicator( + state: TabIndicatorState, + modifier: Modifier = Modifier, ) { val theme = LocalOpenCodeTheme.current - val needsAttention = connectionState == SessionPresence.AWAITING_INPUT - + val needsAttention = state.connectionState == SessionPresence.AWAITING_INPUT val backgroundColor = when { - needsAttention && !isActive -> theme.warning.copy(alpha = 0.15f) - isActive -> theme.backgroundElement + needsAttention && !state.isActive -> theme.warning.copy(alpha = 0.15f) + state.isActive -> theme.backgroundElement else -> theme.background } - Surface( modifier = modifier .height(Sizing.tabHeight) - .clickable(onClick = onClick, role = Role.Tab), - shape = RectangleShape, - color = backgroundColor + .semantics { contentDescription = state.accessibilityLabel } + .clickable(onClick = state.onClick, role = Role.Tab), + color = backgroundColor, ) { - Row( - modifier = Modifier.padding(horizontal = Spacing.xs), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(Spacing.xxs) - ) { - if (connectionState != null) { - SessionStatusDot( - presence = connectionState, - size = if (isActive) Sizing.indicatorDotActive else Sizing.indicatorDot, - ) - } else { - // Icon for non-chat tabs - Icon( - imageVector = icon, - contentDescription = title, - modifier = Modifier.size(Sizing.iconXs), - tint = if (isActive) theme.text else theme.textMuted - ) - } + tabIndicatorRow(state = state, needsAttention = needsAttention) + } +} - // Truncated title - Text( - text = title, - style = MaterialTheme.typography.labelSmall, - color = when { - needsAttention -> theme.warning - isActive -> theme.text - else -> theme.textMuted - }, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.widthIn(max = Sizing.panelWidthSm) +@Composable +private fun tabIndicatorRow(state: TabIndicatorState, needsAttention: Boolean) { + val theme = LocalOpenCodeTheme.current + Row( + modifier = Modifier.padding(horizontal = Spacing.xs), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.xxs), + ) { + tabIndicatorIcon(state) + Text( + text = state.title, + style = MaterialTheme.typography.labelSmall, + color = when { + needsAttention -> theme.warning + state.isActive -> theme.text + else -> theme.textMuted + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = Sizing.panelWidthSm), + ) + if (state.isActive && state.closeable) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.cd_close_tab), + modifier = Modifier + .size(Sizing.iconXs) + .clickable(onClick = state.onClose, role = Role.Button), + tint = theme.textMuted, ) - - // Close button only shows on the active closeable tab. - if (isActive && closeable) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = "Close tab", - modifier = Modifier - .size(Sizing.iconXs) - .clickable(onClick = onClose, role = Role.Button), - tint = theme.textMuted - ) - } } } } +@Composable +private fun tabIndicatorIcon(state: TabIndicatorState) { + val theme = LocalOpenCodeTheme.current + if (state.connectionState != null) { + SessionStatusDot( + presence = state.connectionState, + size = if (state.isActive) Sizing.indicatorDotActive else Sizing.indicatorDot, + ) + } else { + Icon( + imageVector = state.icon, + contentDescription = null, + modifier = Modifier.size(Sizing.iconXs), + tint = if (state.isActive) theme.text else theme.textMuted, + ) + } +} + /** * Helper to get appropriate icon for a screen route. */ @@ -237,6 +286,21 @@ fun rememberTabTitleLabels(): TabTitleLabels = TabTitleLabels( sessionWorkspace = stringResource(R.string.tab_workspace_session), ) +fun getTitleForTab(tab: TabInstance, labels: TabTitleLabels): String { + if (tab.isPinnedHome) return labels.home + val objectTitle = when { + tab.sessionId != null -> tab.sessionTitle?.takeIf { it.isNotBlank() } ?: labels.chat + else -> getTitleForRoute(tab.startRoute, labels, workspaceKey = null) + } + return withWorkspaceSuffix(objectTitle, tab.workspaceKey, labels) +} + +fun getIconForTab(tab: TabInstance): ImageVector = when { + tab.isPinnedHome -> Icons.Default.Home + tab.sessionId != null -> Icons.AutoMirrored.Filled.Chat + else -> getIconForRoute(tab.startRoute) +} + fun getTitleForRoute( route: String?, labels: TabTitleLabels, diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabManager.kt b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabManager.kt index 5f609d92..1572632c 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabManager.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabManager.kt @@ -197,21 +197,25 @@ class TabManager { } } - fun saveState(serverRef: ServerRef): PersistedTabState? { - val currentTabs = _tabs.value + fun saveState(): PersistedTabState? { + val persistedTabs = _tabs.value.mapNotNull { tab -> + if (tab.isPinnedHome) return@mapNotNull null + val serverRef = tab.serverRef ?: return@mapNotNull null + val workspaceKey = tab.workspaceKey ?: return@mapNotNull null + PersistedTab( + id = tab.id, + startRoute = persistableStartRoute(tab), + sessionId = tab.sessionId, + sessionTitle = tab.sessionTitle, + workspaceKey = PersistedWorkspaceKey.fromWorkspaceKey(workspaceKey), + serverEndpointKey = serverRef.endpointKey, + ) + } + if (persistedTabs.isEmpty()) return null return PersistedTabState( - serverEndpointKey = serverRef.endpointKey, - activeTabId = _activeTabId.value, - tabs = currentTabs.filterNot { it.isPinnedHome }.map { tab -> - PersistedTab( - id = tab.id, - startRoute = persistableStartRoute(tab), - sessionId = tab.sessionId, - sessionTitle = tab.sessionTitle, - workspaceKey = tab.workspaceKey?.let(PersistedWorkspaceKey::fromWorkspaceKey), - serverEndpointKey = tab.serverEndpointKey ?: serverRef.endpointKey, - ) - }, + serverEndpointKey = persistedTabs.first().serverEndpointKey!!, + activeTabId = _activeTabId.value?.takeIf { activeId -> persistedTabs.any { it.id == activeId } }, + tabs = persistedTabs, ) } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabNavHost.kt b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabNavHost.kt index 28ea8d2e..240c5a42 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabNavHost.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabNavHost.kt @@ -23,8 +23,7 @@ import androidx.navigation.compose.navigation import androidx.navigation.navArgument import dev.blazelight.p4oc.core.datastore.SettingsDataStore import dev.blazelight.p4oc.core.datastore.VisualSettings -import dev.blazelight.p4oc.core.network.ConnectionManager -import dev.blazelight.p4oc.core.network.ConnectionState +import dev.blazelight.p4oc.core.network.ServerConnectionRegistry import dev.blazelight.p4oc.domain.model.SessionConnectionState import dev.blazelight.p4oc.domain.server.ServerRef import dev.blazelight.p4oc.domain.server.WorkspaceKey @@ -37,6 +36,7 @@ import dev.blazelight.p4oc.ui.screens.files.FileViewerScreen import dev.blazelight.p4oc.ui.screens.files.FilesViewModel import dev.blazelight.p4oc.ui.screens.home.HomeActions import dev.blazelight.p4oc.ui.screens.home.HomeSummaryBuilder +import dev.blazelight.p4oc.ui.screens.home.HomeSummaryInput import dev.blazelight.p4oc.ui.screens.home.homeScreen import dev.blazelight.p4oc.ui.screens.projects.ProjectsScreen import dev.blazelight.p4oc.ui.screens.sessions.SessionListScreen @@ -87,16 +87,11 @@ fun TabNavHost( val settingsDataStore: SettingsDataStore = koinInject() val visualSettings by settingsDataStore.visualSettings.collectAsState(initial = VisualSettings()) val savedServers by settingsDataStore.savedServers.collectAsState(initial = emptyList()) - val connectionManager: ConnectionManager = koinInject() - val connectionState by connectionManager.connectionState.collectAsState() - val homeConnectionStates = remember(savedServers, connectionState, serverRef.endpointKey) { - savedServers.associate { savedServer -> - savedServer.endpointKey to if (savedServer.endpointKey == serverRef.endpointKey) { - connectionState - } else { - ConnectionState.Disconnected - } - } + val serverConnectionRegistry: ServerConnectionRegistry = koinInject() + val homeConnectionStates = savedServers.associate { savedServer -> + val savedServerRef = ServerRef.fromEndpointKey(savedServer.endpointKey, savedServer.displayName) + val state by serverConnectionRegistry.connectionState(savedServerRef).collectAsState() + savedServer.endpointKey to state } val openSubAgentInNewTab = visualSettings.openSubAgentInNewTab val tabs by tabManager.tabs.collectAsState() @@ -176,9 +171,11 @@ fun TabNavHost( composable(Screen.Home.route) { homeScreen( summary = HomeSummaryBuilder.build( - savedServers = savedServers, - connectionStates = homeConnectionStates, - tabs = tabs, + HomeSummaryInput( + savedServers = savedServers, + connectionStates = homeConnectionStates, + tabs = tabs, + ), ), actions = HomeActions( onBrowseSessions = { navController.navigate(Screen.Sessions.route) }, @@ -408,7 +405,13 @@ fun TabNavHost( // Projects screen composable(Screen.Projects.route) { backStackEntry -> - TouchWorkspaceViewModel(backStackEntry, navController, workspaceRoute, workspaceOwner, backStackEntry.destination.route) + TouchWorkspaceViewModel( + backStackEntry, + navController, + workspaceRoute, + workspaceOwner, + backStackEntry.destination.route + ) ProjectsScreen( onNavigateBack = { navController.popBackStack() @@ -432,7 +435,13 @@ fun TabNavHost( navArgument(Screen.Terminal.ARG_PTY_ID) { type = NavType.StringType } ) ) { backStackEntry -> - TouchWorkspaceViewModel(backStackEntry, navController, workspaceRoute, workspaceOwner, backStackEntry.destination.route) + TouchWorkspaceViewModel( + backStackEntry, + navController, + workspaceRoute, + workspaceOwner, + backStackEntry.destination.route + ) TerminalScreen( onPtyLoaded = { ptyId, ptyTitle -> // Update tab binding with PTY id and title @@ -512,7 +521,13 @@ fun TabNavHost( } ) ) { backStackEntry -> - TouchWorkspaceViewModel(backStackEntry, navController, workspaceRoute, workspaceOwner, backStackEntry.destination.route) + TouchWorkspaceViewModel( + backStackEntry, + navController, + workspaceRoute, + workspaceOwner, + backStackEntry.destination.route + ) val encodedContent = backStackEntry.arguments?.getString(Screen.DiffViewer.ARG_CONTENT) ?: "" val encodedFileName = backStackEntry.arguments?.getString(Screen.DiffViewer.ARG_FILE_NAME) ?: "" DiffViewerScreen( @@ -545,7 +560,13 @@ fun TabNavHost( // Settings screens composable(Screen.Settings.route) { backStackEntry -> - TouchWorkspaceViewModel(backStackEntry, navController, workspaceRoute, workspaceOwner, backStackEntry.destination.route) + TouchWorkspaceViewModel( + backStackEntry, + navController, + workspaceRoute, + workspaceOwner, + backStackEntry.destination.route + ) SettingsScreen( onNavigateBack = { navController.popBackStack() }, onDisconnect = onDisconnect, @@ -577,63 +598,117 @@ fun TabNavHost( } composable(Screen.Licenses.route) { backStackEntry -> - TouchWorkspaceViewModel(backStackEntry, navController, workspaceRoute, workspaceOwner, backStackEntry.destination.route) + TouchWorkspaceViewModel( + backStackEntry, + navController, + workspaceRoute, + workspaceOwner, + backStackEntry.destination.route + ) dev.blazelight.p4oc.ui.screens.licenses.LicensesScreen( onNavigateBack = { navController.popBackStack() } ) } composable(Screen.ProviderConfig.route) { backStackEntry -> - TouchWorkspaceViewModel(backStackEntry, navController, workspaceRoute, workspaceOwner, backStackEntry.destination.route) + TouchWorkspaceViewModel( + backStackEntry, + navController, + workspaceRoute, + workspaceOwner, + backStackEntry.destination.route + ) ProviderConfigScreen( onNavigateBack = { navController.popBackStack() } ) } composable(Screen.VisualSettings.route) { backStackEntry -> - TouchWorkspaceViewModel(backStackEntry, navController, workspaceRoute, workspaceOwner, backStackEntry.destination.route) + TouchWorkspaceViewModel( + backStackEntry, + navController, + workspaceRoute, + workspaceOwner, + backStackEntry.destination.route + ) VisualSettingsScreen( onNavigateBack = { navController.popBackStack() } ) } composable(Screen.ChatSettings.route) { backStackEntry -> - TouchWorkspaceViewModel(backStackEntry, navController, workspaceRoute, workspaceOwner, backStackEntry.destination.route) + TouchWorkspaceViewModel( + backStackEntry, + navController, + workspaceRoute, + workspaceOwner, + backStackEntry.destination.route + ) ChatSettingsScreen( onNavigateBack = { navController.popBackStack() } ) } composable(Screen.ModelControls.route) { backStackEntry -> - TouchWorkspaceViewModel(backStackEntry, navController, workspaceRoute, workspaceOwner, backStackEntry.destination.route) + TouchWorkspaceViewModel( + backStackEntry, + navController, + workspaceRoute, + workspaceOwner, + backStackEntry.destination.route + ) ModelControlsScreen( onNavigateBack = { navController.popBackStack() } ) } composable(Screen.AgentsConfig.route) { backStackEntry -> - TouchWorkspaceViewModel(backStackEntry, navController, workspaceRoute, workspaceOwner, backStackEntry.destination.route) + TouchWorkspaceViewModel( + backStackEntry, + navController, + workspaceRoute, + workspaceOwner, + backStackEntry.destination.route + ) AgentsConfigScreen( onNavigateBack = { navController.popBackStack() } ) } composable(Screen.Skills.route) { backStackEntry -> - TouchWorkspaceViewModel(backStackEntry, navController, workspaceRoute, workspaceOwner, backStackEntry.destination.route) + TouchWorkspaceViewModel( + backStackEntry, + navController, + workspaceRoute, + workspaceOwner, + backStackEntry.destination.route + ) SkillsScreen( onNavigateBack = { navController.popBackStack() } ) } composable(Screen.NotificationSettings.route) { backStackEntry -> - TouchWorkspaceViewModel(backStackEntry, navController, workspaceRoute, workspaceOwner, backStackEntry.destination.route) + TouchWorkspaceViewModel( + backStackEntry, + navController, + workspaceRoute, + workspaceOwner, + backStackEntry.destination.route + ) NotificationSettingsScreen( onNavigateBack = { navController.popBackStack() } ) } composable(Screen.ConnectionSettings.route) { backStackEntry -> - TouchWorkspaceViewModel(backStackEntry, navController, workspaceRoute, workspaceOwner, backStackEntry.destination.route) + TouchWorkspaceViewModel( + backStackEntry, + navController, + workspaceRoute, + workspaceOwner, + backStackEntry.destination.route + ) ConnectionSettingsScreen( onNavigateBack = { navController.popBackStack() } ) diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/theme/ProjectColors.kt b/app/src/main/java/dev/blazelight/p4oc/ui/theme/ProjectColors.kt index b9ed0220..3d5ee442 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/theme/ProjectColors.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/theme/ProjectColors.kt @@ -38,12 +38,12 @@ object ProjectColors { @Composable fun textColorForProject(projectId: String): Color { val bgColor = colorForProject(projectId) - val theme = LocalOpenCodeTheme.current - // Use luminance to pick contrasting text - dark text on bright bg, light text on dark bg + // These are contrast colors, not theme roles: theme.text/background swap luminance + // between modes and therefore invert this decision in light themes. return if (bgColor.luminance() > 0.4f) { - theme.background // Dark text for bright backgrounds + Color.Black } else { - theme.text // Light text for dark backgrounds + Color.White } } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/theme/opencode/FallbackTheme.kt b/app/src/main/java/dev/blazelight/p4oc/ui/theme/opencode/FallbackTheme.kt index c92cd854..5720cc2f 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/theme/opencode/FallbackTheme.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/theme/opencode/FallbackTheme.kt @@ -66,7 +66,8 @@ fun createFallbackTheme(isDark: Boolean): OpenCodeTheme { secondary = Color(0xFF8839EF), accent = Color(0xFFEA76CB), text = Color(0xFF4C4F69), - textMuted = Color(0xFF5C5F77), + // Keep small secondary text safely above AA across every light surface. + textMuted = Color(0xFF52556D), background = Color(0xFFEFF1F5), error = Color(0xFFD20F39), warning = Color(0xFFDF8E1D), diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/workspace/WorkspaceRepositoryOwner.kt b/app/src/main/java/dev/blazelight/p4oc/ui/workspace/WorkspaceRepositoryOwner.kt index 051bb755..fdcbb2ac 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/workspace/WorkspaceRepositoryOwner.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/workspace/WorkspaceRepositoryOwner.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch class WorkspaceRepositoryOwner( val tabId: String, @@ -35,6 +36,9 @@ class WorkspaceRepositoryOwner( init { AppLog.i(TAG, logPrefix("init")) + uploadScope.launch { + sessionRepository.refresh() + } } fun touch(destinationRoute: String?) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c3dc4d31..dd3e1a0c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -13,21 +13,42 @@ Network Server Connect to an OpenCode server running on your network. Recent Servers - Saved servers - Connect to saved targets. Use actions for edit or forget. + Your servers + Tap a server to connect. More actions are available from its menu. + Nearby + Add server + Edit server + Enter server address + Credentials and security TLS checks on TLS checks off + Authentication configured + Default authentication Server actions Server actions for %1$s Forget server + Review tabs + Close tabs and forget Forget %1$s? - %1$d open tab(s) are using this server. Existing tabs will stay open until closed or reconnected, but this server will be removed from saved targets. - This server will be removed from saved targets. + %1$d open tab(s) are using this server. Existing tabs must be reviewed or closed before this server and its saved credentials can be removed. + This server and its saved credentials will be removed. %1$d open tab(s) + Open settings + Show or hide password + connected + connecting + nearby + offline + connection error + Server is connected and idle + Server is connecting or retrying + Server is available nearby + Server is disconnected + Server connection failed Saved server - Discovered Servers + Nearby scanning Scanning local network for OpenCode servers… Discovered server @@ -154,7 +175,8 @@ Sessions No sessions yet - Tap above to start a new session + Use + to start work in this scope + Resume %1$s, workspace %2$s, updated %3$s Search session titles… No matching sessions Searching… @@ -229,8 +251,12 @@ Files File Projects - Global - Session + No project context + Session context + %1$s, %2$s, %3$s + Session %1$s + Close tab + Start new work Back @@ -821,4 +847,16 @@ done failed unavailable + Start work + In + Change + Choose a server and workspace + No servers configured. Add a server to start work. + New chat + Files + Terminal + Open in this exact server and workspace + Existing work + Sessions + Browse sessions in this exact workspace diff --git a/app/src/test/java/dev/blazelight/p4oc/core/network/ServerConnectionRegistryTest.kt b/app/src/test/java/dev/blazelight/p4oc/core/network/ServerConnectionRegistryTest.kt index 78e0d637..7fbd2e29 100644 --- a/app/src/test/java/dev/blazelight/p4oc/core/network/ServerConnectionRegistryTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/core/network/ServerConnectionRegistryTest.kt @@ -10,7 +10,7 @@ import io.mockk.mockk import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Test @@ -24,7 +24,7 @@ class ServerConnectionRegistryTest { val beta = SavedServerRegistry.fromConnection("http://beta.example.com", "Beta") val alphaManager = successfulManager(alpha) val betaManager = successfulManager(beta) - val registry = registryFor(this) { config -> + val registry = registryFor(backgroundScope) { config -> when (config.url) { alpha.endpoint -> alphaManager beta.endpoint -> betaManager @@ -34,7 +34,7 @@ class ServerConnectionRegistryTest { registry.connect(alpha) registry.connect(beta) - advanceUntilIdle() + runCurrent() assertEquals(ConnectionState.Connected, registry.connectionState(alpha.toServerRef()).value) assertEquals(ConnectionState.Connected, registry.connectionState(beta.toServerRef()).value) @@ -48,7 +48,7 @@ class ServerConnectionRegistryTest { val beta = SavedServerRegistry.fromConnection("http://beta.example.com", "Beta") val alphaManager = successfulManager(alpha) val betaManager = failingManager(beta, "auth failed") - val registry = registryFor(this) { config -> + val registry = registryFor(backgroundScope) { config -> when (config.url) { alpha.endpoint -> alphaManager beta.endpoint -> betaManager @@ -58,19 +58,42 @@ class ServerConnectionRegistryTest { registry.connect(alpha) registry.connect(beta) - advanceUntilIdle() + runCurrent() assertEquals(ConnectionState.Connected, registry.connectionState(alpha.toServerRef()).value) assertEquals(ConnectionState.Error("auth failed"), registry.connectionState(beta.toServerRef()).value) } + @Test + fun `registry follows manager recovery after connect returns`() = runTest { + val server = SavedServerRegistry.fromConnection("http://recovering.example.com", "Recovering") + val managerState = MutableStateFlow(ConnectionState.Error("network unavailable")) + val manager = mockk(relaxed = true) + every { manager.connection } returns MutableStateFlow(null) + every { manager.connectionState } returns managerState + coEvery { manager.connect(server.toServerConfig(), any()) } returns Result.success(emptyList()) + val registry = registryFor(backgroundScope) { manager } + + registry.connect(server) + runCurrent() + assertEquals( + ConnectionState.Error("network unavailable"), + registry.connectionState(server.toServerRef()).value, + ) + + managerState.value = ConnectionState.Connected + runCurrent() + + assertEquals(ConnectionState.Connected, registry.connectionState(server.toServerRef()).value) + } + @Test fun `disconnect only clears the targeted server`() = runTest { val alpha = SavedServerRegistry.fromConnection("http://alpha.example.com", "Alpha") val beta = SavedServerRegistry.fromConnection("http://beta.example.com", "Beta") val alphaManager = successfulManager(alpha) val betaManager = successfulManager(beta) - val registry = registryFor(this) { config -> + val registry = registryFor(backgroundScope) { config -> when (config.url) { alpha.endpoint -> alphaManager beta.endpoint -> betaManager @@ -79,7 +102,7 @@ class ServerConnectionRegistryTest { } registry.connect(alpha) registry.connect(beta) - advanceUntilIdle() + runCurrent() registry.disconnect(alpha.toServerRef()) @@ -89,6 +112,34 @@ class ServerConnectionRegistryTest { coVerify(exactly = 0) { betaManager.disconnect() } } + @Test + fun `connect saved server uses persisted password when caller omits one`() = runTest { + val server = SavedServerRegistry.fromConnection("http://authenticated.example.com", "Authenticated") + val settings = mockk() + coEvery { settings.getSavedServerPassword(server) } returns "persisted-password" + val manager = successfulManager(server) + val registry = ServerConnectionRegistry(settings, { manager }, backgroundScope) + + registry.connect(server) + runCurrent() + + coVerify(exactly = 1) { manager.connect(server.toServerConfig(), "persisted-password") } + } + + @Test + fun `connect saved server keeps explicit password authoritative`() = runTest { + val server = SavedServerRegistry.fromConnection("http://authenticated.example.com", "Authenticated") + val settings = mockk() + val manager = successfulManager(server) + val registry = ServerConnectionRegistry(settings, { manager }, backgroundScope) + + registry.connect(server, "explicit-password") + runCurrent() + + coVerify(exactly = 1) { manager.connect(server.toServerConfig(), "explicit-password") } + coVerify(exactly = 0) { settings.getSavedServerPassword(any()) } + } + @Test fun `reconnectAll reconnects only saved open-tab servers`() = runTest { val alpha = SavedServerRegistry.fromConnection("http://alpha.example.com", "Alpha") @@ -106,10 +157,10 @@ class ServerConnectionRegistryTest { beta.endpoint -> betaManager else -> error("unexpected config $config") } - }, this) + }, backgroundScope) registry.reconnectAll(setOf(alpha.toServerRef(), missing)) - advanceUntilIdle() + runCurrent() assertEquals(ConnectionState.Connected, registry.connectionState(alpha.toServerRef()).value) assertEquals(ConnectionState.Disconnected, registry.connectionState(beta.toServerRef()).value) @@ -120,7 +171,11 @@ class ServerConnectionRegistryTest { private fun registryFor( scope: CoroutineScope, factory: (ServerConfig) -> ConnectionManager, - ): ServerConnectionRegistry = ServerConnectionRegistry(mockk(relaxed = true), factory, scope) + ): ServerConnectionRegistry { + val settings = mockk() + coEvery { settings.getSavedServerPassword(any()) } returns null + return ServerConnectionRegistry(settings, factory, scope) + } private fun successfulManager(server: dev.blazelight.p4oc.core.datastore.SavedServer): ConnectionManager { val manager = mockk(relaxed = true) diff --git a/app/src/test/java/dev/blazelight/p4oc/data/session/SessionRepositoryImplTest.kt b/app/src/test/java/dev/blazelight/p4oc/data/session/SessionRepositoryImplTest.kt index b3d9e977..1fabc737 100644 --- a/app/src/test/java/dev/blazelight/p4oc/data/session/SessionRepositoryImplTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/data/session/SessionRepositoryImplTest.kt @@ -203,6 +203,25 @@ class SessionRepositoryImplTest { assertEquals("/repo/p1", sessions.getValue("same").session.directory) } + @Test + fun `refresh explicitly requests complete session history for every scope`() = runTest { + val client = FakeWorkspaceClient().apply { + projects = listOf(FakeWorkspaceClient.projectDto("p1", "/repo/p1")) + sessionsByDirectory = mapOf(null to emptyList(), "/repo/p1" to emptyList()) + } + val repository = SessionRepositoryImpl( + client, + nowMs = { testScheduler.currentTime }, + dispatcher = StandardTestDispatcher(testScheduler), + ) + + repository.refresh() + + assertEquals(2, client.listSessionsCallsLog.size) + assertTrue(client.listSessionsCallsLog.all { it.limit == Int.MAX_VALUE }) + assertTrue(client.listSessionsCallsLog.all { it.start == null }) + } + @Test fun `searchSessionsInWorkspace searches only requested directory`() = runTest { val client = FakeWorkspaceClient().apply { diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/components/code/TextMateAnnotatedStringTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/components/code/TextMateAnnotatedStringTest.kt index aa8f5d62..c0e97ef5 100644 --- a/app/src/test/java/dev/blazelight/p4oc/ui/components/code/TextMateAnnotatedStringTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/ui/components/code/TextMateAnnotatedStringTest.kt @@ -138,9 +138,9 @@ class TextMateAnnotatedStringTest { python = load(Registry(), File(root, "python/python.tmLanguage.json")) typescript = load(Registry(), File(root, "typescript/typescript.tmLanguage.json")) markdown = load(Registry(), File(root, "markdown/markdown.tmLanguage.json")) - assertNotNull(kotlin); - assertNotNull(python); - assertNotNull(typescript); + assertNotNull(kotlin) + assertNotNull(python) + assertNotNull(typescript) assertNotNull(markdown) } } diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModelTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModelTest.kt index 748668c3..86369f62 100644 --- a/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModelTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModelTest.kt @@ -404,7 +404,6 @@ class ChatViewModelTest { assertTrue(vm.uiState.value.error?.contains("boom") == true) } - @Test fun sendMessage_sendsBackendFileUrls_forWorkspaceAttachmentsWithSpecialCharacters() = runTest { val vm = createViewModel() @@ -438,7 +437,6 @@ class ChatViewModelTest { ) } - @Test fun abortSession_clearsStreamingFlags_andBusyState() = runTest { val vm = createViewModel() diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/screens/home/HomeSummaryBuilderTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/screens/home/HomeSummaryBuilderTest.kt index 0b1cb915..c75eb428 100644 --- a/app/src/test/java/dev/blazelight/p4oc/ui/screens/home/HomeSummaryBuilderTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/ui/screens/home/HomeSummaryBuilderTest.kt @@ -1,62 +1,396 @@ package dev.blazelight.p4oc.ui.screens.home +import dev.blazelight.p4oc.core.datastore.SavedServer import dev.blazelight.p4oc.core.datastore.SavedServerRegistry import dev.blazelight.p4oc.core.network.ConnectionState +import dev.blazelight.p4oc.data.session.RepoState +import dev.blazelight.p4oc.data.session.Snapshot +import dev.blazelight.p4oc.domain.model.Session +import dev.blazelight.p4oc.domain.model.SessionPresence +import dev.blazelight.p4oc.domain.model.SessionStatus import dev.blazelight.p4oc.domain.server.ServerRef import dev.blazelight.p4oc.domain.server.WorkspaceKey +import dev.blazelight.p4oc.domain.session.SessionId +import dev.blazelight.p4oc.domain.session.WorkspaceSession +import dev.blazelight.p4oc.domain.workspace.Workspace import dev.blazelight.p4oc.ui.navigation.Screen import dev.blazelight.p4oc.ui.tabs.TabInstance import dev.blazelight.p4oc.ui.tabs.TabState import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test class HomeSummaryBuilderTest { @Test - fun `bounded summary uses open tabs without chat histories`() { - val server = SavedServerRegistry.fromConnection("http://alpha.example.com", "Alpha") - val serverRef = ServerRef.fromEndpointKey(server.endpointKey, server.displayName) + fun `summary applies independent workspace and open work limits`() { + val (server, serverRef) = server("http://alpha.example.com", "Alpha") val tabs = (1..30).map { index -> - TabInstance( - state = TabState( + tab( + TabInput( id = "tab-$index", - workspaceKey = WorkspaceKey.Directory("/repo-$index"), serverRef = serverRef, + directory = "/repo-$index", + route = Screen.Chat.createRoute("session-$index"), sessionId = "session-$index", sessionTitle = "Session $index", ), - startRoute = Screen.Chat.createRoute("session-$index"), ) } - val summary = HomeSummaryBuilder.build( - savedServers = listOf(server), - connectionStates = mapOf(server.endpointKey to ConnectionState.Connected), + val summary = build( + servers = listOf(server), tabs = tabs, workspaceLimit = 5, openWorkLimit = 7, ) - assertEquals(1, summary.servers.size) assertEquals(30, summary.servers.single().openTabCount) - assertEquals(7, summary.openWork.size) + assertEquals((1..7).map { "tab-$it" }, summary.openWork.map { it.tabId }) assertEquals(5, summary.workspaces.size) - assertEquals("tab-1", summary.openWork.first().tabId) } @Test - fun `offline server summary does not block connected server summary`() { - val connected = SavedServerRegistry.fromConnection("http://alpha.example.com", "Alpha") - val offline = SavedServerRegistry.fromConnection("http://beta.example.com", "Beta") + fun `offline server does not prevent connected server summary`() { + val (connected, _) = server("http://alpha.example.com", "Alpha") + val (offline, _) = server("http://beta.example.com", "Beta") + val summary = HomeSummaryBuilder.build( - savedServers = listOf(connected, offline), - connectionStates = mapOf(connected.endpointKey to ConnectionState.Connected), - tabs = emptyList(), + HomeSummaryInput( + savedServers = listOf(connected, offline), + connectionStates = mapOf(connected.endpointKey to ConnectionState.Connected), + tabs = emptyList(), + ), ) - assertEquals(2, summary.servers.size) assertEquals(ConnectionState.Connected, summary.servers.first { it.displayName == "Alpha" }.connectionState) assertEquals(ConnectionState.Disconnected, summary.servers.first { it.displayName == "Beta" }.connectionState) assertTrue(summary.partialFailures.isEmpty()) } + + @Test + fun `hydrated sessions create workspaces and recency ordered previews without open tabs`() { + val (server, serverRef) = server("http://alpha.example.com", "Alpha") + val repository = scopedRepository( + serverRef, + RepoState.Live( + snapshot( + workspaceSession(serverRef, "older", "/one", "Older", updatedAt = 10L), + workspaceSession(serverRef, "newer", "/two", "", updatedAt = 30L), + statuses = mapOf("newer" to SessionStatus.Busy), + ), + ), + ) + + val summary = build(servers = listOf(server), repositories = listOf(repository)) + + assertEquals(2, summary.servers.single().sessionCount) + assertEquals(listOf("newer", "older"), summary.sessions.map { it.sessionId.value }) + assertEquals(listOf(30L, 10L), summary.sessions.map { it.updatedAt }) + assertEquals("Untitled session", summary.sessions.first().title) + assertEquals(SessionPresence.BUSY, summary.sessions.first().status) + assertEquals( + listOf("/two", "/one"), + summary.workspaces.map { (it.workspaceKey as WorkspaceKey.Directory).value }, + ) + assertTrue(summary.workspaces.all { it.sessionCount == 1 && it.openTabCount == 0 }) + } + + @Test + fun `same directory and session id remain scoped to their owning server`() { + val (alpha, alphaRef) = server("http://alpha.example.com", "Alpha") + val (beta, betaRef) = server("http://beta.example.com", "Beta") + val alphaRepository = scopedRepository( + alphaRef, + RepoState.Live(snapshot(workspaceSession(alphaRef, "shared", "/repo", "Alpha work", 10L))), + ) + val betaRepository = scopedRepository( + betaRef, + RepoState.Live( + snapshot( + workspaceSession(betaRef, "shared", "/repo", "Beta work", 20L), + statuses = mapOf("shared" to SessionStatus.Busy), + ), + ), + ) + val betaTab = tab( + TabInput( + id = "beta-chat", + serverRef = betaRef, + directory = "/repo", + route = Screen.Chat.createRoute("shared"), + sessionId = "shared", + ), + ) + + val summary = build( + servers = listOf(alpha, beta), + tabs = listOf(betaTab), + repositories = listOf(alphaRepository, betaRepository), + ) + + assertEquals( + setOf(alphaRef.endpointKey, betaRef.endpointKey), + summary.workspaces.map { it.serverRef.endpointKey }.toSet(), + ) + assertEquals( + 0, + summary.workspaces.single { workspace -> + workspace.serverRef.endpointKey == alphaRef.endpointKey + }.openTabCount, + ) + assertEquals( + 1, + summary.workspaces.single { workspace -> + workspace.serverRef.endpointKey == betaRef.endpointKey + }.openTabCount, + ) + assertEquals("Beta work", summary.openWork.single().title) + assertEquals(SessionPresence.BUSY, summary.openWork.single().status) + } + + @Test + fun `chat tab does not borrow session details from another workspace`() { + val (server, serverRef) = server("http://alpha.example.com", "Alpha") + val repository = scopedRepository( + serverRef, + RepoState.Live( + snapshot( + workspaceSession(serverRef, "shared", "/other", "Other workspace", 10L), + statuses = mapOf("shared" to SessionStatus.Busy), + ), + ), + ) + val chat = tab( + TabInput( + id = "target-chat", + serverRef = serverRef, + directory = "/target", + route = Screen.Chat.createRoute("shared"), + sessionId = "shared", + ), + ) + + val openWork = build( + servers = listOf(server), + tabs = listOf(chat), + repositories = listOf(repository), + ).openWork.single() + + assertEquals("Chat", openWork.title) + assertEquals(null, openWork.status) + } + + @Test + fun `open work is typed and excludes home unowned and unsupported tabs`() { + val (server, serverRef) = server("http://alpha.example.com", "Alpha") + val tabs = listOf( + TabInstance.home(), + tab( + TabInput( + id = "chat", + serverRef = serverRef, + directory = "/repo", + route = Screen.Chat.createRoute("s1"), + sessionId = "s1", + sessionTitle = "Fix login", + ), + ), + tab(TabInput("files", serverRef, "/repo", "files/src")), + tab(TabInput("terminal", serverRef, "/repo", "terminal/pty-1")), + tab(TabInput("unsupported", serverRef, "/repo", "settings")), + TabInstance(TabState(id = "unowned"), startRoute = "files"), + ) + + val summary = build(servers = listOf(server), tabs = tabs) + + assertEquals(listOf("chat", "files", "terminal"), summary.openWork.map { it.tabId }) + assertEquals( + listOf(OpenWorkType.Chat, OpenWorkType.Files, OpenWorkType.Terminal), + summary.openWork.map { it.type }, + ) + assertEquals( + listOf("Fix login", "Files", "Terminal"), + summary.openWork.map { it.title }, + ) + assertEquals(4, summary.servers.single().openTabCount) + } + + @Test + fun `hydrating and stale repositories preserve snapshots while surfacing partial state`() { + val (alpha, alphaRef) = server("http://alpha.example.com", "Alpha") + val (beta, betaRef) = server("http://beta.example.com", "Beta") + val hydrating = scopedRepository( + alphaRef, + RepoState.Hydrating(snapshot = snapshot(workspaceSession(alphaRef, "loading", "/alpha", "Loading", 5L))), + ) + val stale = scopedRepository( + betaRef, + RepoState.Stale( + snapshot = snapshot(workspaceSession(betaRef, "cached", "/beta", "Cached", 7L)), + reason = "network unavailable", + ), + ) + + val summary = build(servers = listOf(alpha, beta), repositories = listOf(hydrating, stale)) + + assertTrue(summary.isLoading) + assertTrue(summary.servers.single { it.serverRef.endpointKey == alphaRef.endpointKey }.isLoading) + assertFalse(summary.servers.single { it.serverRef.endpointKey == betaRef.endpointKey }.isLoading) + assertEquals( + "network unavailable", + summary.servers.single { it.serverRef.endpointKey == betaRef.endpointKey }.failure, + ) + assertEquals(listOf("Beta: network unavailable"), summary.partialFailures) + assertEquals(setOf("loading", "cached"), summary.sessions.map { it.sessionId.value }.toSet()) + assertEquals(2, summary.workspaces.size) + } + + @Test + fun `workspace bound does not truncate sessions and sessions remain newest first`() { + val (server, serverRef) = server("http://alpha.example.com", "Alpha") + val sessions = (1..40).map { index -> + workspaceSession(serverRef, "session-$index", "/repo-$index", "Work $index", index.toLong()) + }.toTypedArray() + + val summary = build( + servers = listOf(server), + repositories = listOf(scopedRepository(serverRef, RepoState.Live(snapshot(*sessions)))), + workspaceLimit = 5, + ) + + assertEquals(5, summary.workspaces.size) + assertEquals(40, summary.sessions.size) + assertEquals((40 downTo 1).map { "session-$it" }, summary.sessions.map { it.sessionId.value }) + } + + @Test + fun `home search spans every server even when browse scope is selected`() { + val (alpha, alphaRef) = server("http://alpha.example.com", "Alpha") + val (beta, betaRef) = server("http://beta.example.com", "Beta") + val summary = build( + servers = listOf(alpha, beta), + repositories = listOf( + scopedRepository( + alphaRef, + RepoState.Live( + snapshot( + workspaceSession(alphaRef, "old", "/shared/Needle", "Old task", 10L), + workspaceSession(alphaRef, "new", "/other", "NEEDLE title", 30L), + ), + ), + ), + scopedRepository( + betaRef, + RepoState.Live( + snapshot( + workspaceSession(betaRef, "beta", "/needle", "Needle beta", 50L), + ), + ), + ), + ), + ) + + val filtered = summary.filteredHomeResults(alphaRef.endpointKey, " needle ") + + assertEquals(listOf("beta", "new", "old"), filtered.sessions.map { it.sessionId.value }) + assertEquals( + setOf(alphaRef.endpointKey, betaRef.endpointKey), + filtered.workspaces.map { it.serverRef.endpointKey }.toSet(), + ) + } + + @Test + fun `blank home search preserves bounded workspaces and complete session order`() { + val (server, serverRef) = server("http://alpha.example.com", "Alpha") + val repository = scopedRepository( + serverRef, + RepoState.Live( + snapshot( + workspaceSession(serverRef, "one", "/one", "One", 1L), + workspaceSession(serverRef, "three", "/three", "Three", 3L), + workspaceSession(serverRef, "two", "/two", "Two", 2L), + ), + ), + ) + val summary = build(listOf(server), repositories = listOf(repository), workspaceLimit = 2) + + val filtered = summary.filteredHomeResults(null, " ") + + assertEquals(2, filtered.workspaces.size) + assertEquals(listOf("three", "two", "one"), filtered.sessions.map { it.sessionId.value }) + } + + private fun build( + servers: List, + tabs: List = emptyList(), + repositories: List = emptyList(), + workspaceLimit: Int = 12, + openWorkLimit: Int = 24, + ): HomeSummaryState = HomeSummaryBuilder.build( + HomeSummaryInput( + savedServers = servers, + connectionStates = servers.associate { it.endpointKey to ConnectionState.Connected }, + tabs = tabs, + repositories = repositories, + workspaceLimit = workspaceLimit, + openWorkLimit = openWorkLimit, + ), + ) + + private fun server(url: String, name: String): Pair { + val saved = SavedServerRegistry.fromConnection(url, name) + return saved to ServerRef.fromEndpointKey(saved.endpointKey, saved.displayName) + } + + private fun scopedRepository(serverRef: ServerRef, state: RepoState) = + ScopedHomeRepositoryState(serverRef, state) + + private fun snapshot( + vararg sessions: WorkspaceSession, + statuses: Map = emptyMap(), + ) = Snapshot( + sessions = sessions.associateBy { it.id.value }, + statuses = statuses, + ) + + private fun workspaceSession( + serverRef: ServerRef, + id: String, + directory: String, + title: String, + updatedAt: Long, + ) = WorkspaceSession( + id = SessionId(id), + workspace = Workspace(serverRef, directory), + session = Session( + id = id, + projectID = "project-$id", + directory = directory, + title = title, + version = "1", + createdAt = 1L, + updatedAt = updatedAt, + ), + ) + + private data class TabInput( + val id: String, + val serverRef: ServerRef, + val directory: String, + val route: String, + val sessionId: String? = null, + val sessionTitle: String? = null, + ) + + private fun tab(input: TabInput) = TabInstance( + state = TabState( + id = input.id, + workspaceKey = WorkspaceKey.Directory(input.directory), + serverRef = input.serverRef, + sessionId = input.sessionId, + sessionTitle = input.sessionTitle, + ), + startRoute = input.route, + ) } diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/tabs/StartWorkContextTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/tabs/StartWorkContextTest.kt index 1f45f22b..b5b135f3 100644 --- a/app/src/test/java/dev/blazelight/p4oc/ui/tabs/StartWorkContextTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/ui/tabs/StartWorkContextTest.kt @@ -69,21 +69,101 @@ class StartWorkContextTest { } @Test - fun `removed server invalidates selection instead of retargeting same directory`() { - val selection = StartWorkSelection.Selected(StartWorkTarget(alpha, workspace)) + fun `removed server preserves exact target and pending action without retargeting`() { + val target = StartWorkTarget(alpha, workspace) + val context = StartWorkContext( + source = StartWorkSource.FilesTab, + selection = StartWorkSelection.Selected(target), + defaultAction = StartWorkAction.Files, + ) + + val resolved = context.resolve( + availableServers = listOf(beta), + availableWorkspaces = listOf(StartWorkTarget(beta, workspace)), + connectionStates = mapOf(beta.endpointKey to StartWorkConnectionState.Online), + ) + + assertEquals(StartWorkAvailability.ServerRemoved, resolved.availability) + assertEquals(context, resolved.context) + assertEquals(target, resolved.target) + assertEquals(StartWorkAction.Files, resolved.pendingAction) + } + + @Test + fun `missing workspace preserves exact target and pending action`() { + val target = StartWorkTarget(alpha, workspace) + val context = StartWorkContext( + source = StartWorkSource.ChatTab, + selection = StartWorkSelection.Selected(target), + defaultAction = StartWorkAction.NewChat, + ) - val validated = selection.validatedAgainst(listOf(beta)) + val resolved = context.resolve( + availableServers = listOf(alpha), + availableWorkspaces = emptyList(), + connectionStates = mapOf(alpha.endpointKey to StartWorkConnectionState.Online), + ) + + assertEquals(StartWorkAvailability.WorkspaceMissing, resolved.availability) + assertEquals(context, resolved.context) + assertEquals(target, resolved.target) + assertEquals(StartWorkAction.NewChat, resolved.pendingAction) + } + + @Test + fun `availability distinguishes no servers offline and authentication recovery`() { + val target = StartWorkTarget(alpha, workspace) + val context = StartWorkContext( + source = StartWorkSource.TerminalTab, + selection = StartWorkSelection.Selected(target), + defaultAction = StartWorkAction.Terminal, + ) + val cases = listOf( + Triple(emptyList(), emptyMap(), StartWorkAvailability.NoServers), + Triple( + listOf(alpha), + mapOf(alpha.endpointKey to StartWorkConnectionState.Offline), + StartWorkAvailability.Offline, + ), + Triple( + listOf(alpha), + mapOf(alpha.endpointKey to StartWorkConnectionState.AuthRequired), + StartWorkAvailability.AuthRequired, + ), + ) - assertSame(StartWorkSelection.NeedsSelection, validated) + cases.forEach { (servers, states, expectedAvailability) -> + val resolved = context.resolve( + availableServers = servers, + availableWorkspaces = listOf(target), + connectionStates = states, + ) + + assertEquals(expectedAvailability, resolved.availability) + assertEquals(context, resolved.context) + assertEquals(target, resolved.target) + assertEquals(StartWorkAction.Terminal, resolved.pendingAction) + } } @Test - fun `explicit no-project selection survives validation when its server remains saved`() { - val selection = StartWorkSelection.Selected(StartWorkTarget(alpha, WorkspaceKey.Global)) + fun `explicit global target is ready without a directory workspace entry`() { + val target = StartWorkTarget(alpha, WorkspaceKey.Global) + val context = StartWorkContext( + source = StartWorkSource.HomeWorkspaceDetail, + selection = StartWorkSelection.Selected(target), + defaultAction = StartWorkAction.BrowseSessions, + ) - val validated = selection.validatedAgainst(listOf(alpha, beta)) + val resolved = context.resolve( + availableServers = listOf(alpha), + availableWorkspaces = emptyList(), + connectionStates = mapOf(alpha.endpointKey to StartWorkConnectionState.Online), + ) - assertEquals(selection, validated) + assertEquals(StartWorkAvailability.Ready, resolved.availability) + assertEquals(target, resolved.target) + assertEquals(StartWorkAction.BrowseSessions, resolved.pendingAction) } @Test @@ -112,4 +192,44 @@ class StartWorkContextTest { assertEquals(StartWorkSource.TerminalTab, terminal.source) assertEquals(target, terminal.selectedTarget) } + + @Test + fun `picker groups put exact global target first and include unopened Home workspaces`() { + val unopened = StartWorkTarget(alpha, WorkspaceKey.Directory("/unopened")) + val groups = buildStartWorkPickerGroups( + servers = listOf(Triple(alpha.endpointKey, "Alpha", "A")), + openTargets = emptyList(), + knownHomeTargets = listOf(unopened), + ) + + assertEquals(alpha.endpointKey, groups.single().server.endpointKey) + assertEquals(WorkspaceKey.Global, groups.single().targets.first().workspaceKey) + assertEquals(unopened.workspaceKey, groups.single().targets[1].workspaceKey) + } + + @Test + fun `picker deduplicates within a server without merging same path across servers`() { + val alphaTarget = StartWorkTarget(alpha, workspace) + val betaTarget = StartWorkTarget(beta, workspace) + val groups = buildStartWorkPickerGroups( + servers = listOf( + Triple(alpha.endpointKey, "Alpha", "A"), + Triple(beta.endpointKey, "Beta", "B"), + ), + openTargets = listOf(alphaTarget, betaTarget), + knownHomeTargets = listOf(alphaTarget), + ) + + assertEquals(listOf(WorkspaceKey.Global, workspace), groups[0].targets.map { it.workspaceKey }) + assertEquals(listOf(WorkspaceKey.Global, workspace), groups[1].targets.map { it.workspaceKey }) + assertTrue(groups[0].targets[1].serverRef != groups[1].targets[1].serverRef) + } + + @Test + fun `scoped actions retain new chat files terminal order`() { + assertEquals( + listOf(StartWorkAction.NewChat, StartWorkAction.Files, StartWorkAction.Terminal), + startWorkScopedActionOrder, + ) + } } diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/tabs/TabBarTitleTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/tabs/TabBarTitleTest.kt index 15c37ef3..d22de7fe 100644 --- a/app/src/test/java/dev/blazelight/p4oc/ui/tabs/TabBarTitleTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/ui/tabs/TabBarTitleTest.kt @@ -1,5 +1,6 @@ package dev.blazelight.p4oc.ui.tabs +import dev.blazelight.p4oc.domain.server.ServerRef import dev.blazelight.p4oc.domain.server.WorkspaceKey import dev.blazelight.p4oc.domain.session.SessionId import org.junit.Assert.assertEquals @@ -20,6 +21,69 @@ class TabBarTitleTest { sessionWorkspace = "Session", ) + private val server = ServerRef.fromEndpointKey( + endpointKey = "https://work.example.test", + displayName = "Production", + ) + + @Test + fun `pinned Home keeps explicit identity despite work-like state`() { + val home = TabInstance( + state = TabState( + id = TabInstance.HOME_TAB_ID, + sessionId = "session-1", + sessionTitle = "Investigate bug", + workspaceKey = WorkspaceKey.Directory("/repo/project"), + serverRef = server, + pinnedHome = true, + ), + startRoute = "diff/1", + ) + + assertEquals("Home", getTitleForTab(home, labels)) + } + + @Test + fun `work title is derived from its start route and workspace rather than server presentation`() { + val work = TabInstance( + state = TabState( + workspaceKey = WorkspaceKey.Directory("/repo/project"), + serverRef = server, + ), + startRoute = "sessions", + ) + + assertEquals("Sessions · project", getTitleForTab(work, labels)) + } + + @Test + fun `session work title is derived from its session title and workspace`() { + val work = TabInstance( + state = TabState( + sessionId = "session-1", + sessionTitle = "Investigate bug", + workspaceKey = WorkspaceKey.Directory("/repo/project"), + serverRef = server, + ), + startRoute = "sessions", + ) + + assertEquals("Investigate bug · project", getTitleForTab(work, labels)) + } + + @Test + fun `global work uses localized No project context label`() { + val work = TabInstance( + state = TabState(workspaceKey = WorkspaceKey.Global, serverRef = server), + startRoute = "sessions", + ) + + assertEquals( + "Sessions · No project context", + getTitleForTab(work, labels.copy(globalWorkspace = "No project context")), + ) + } + @Test fun `sessions route includes global workspace suffix`() { assertEquals( diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/tabs/TabManagerPersistenceTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/tabs/TabManagerPersistenceTest.kt index ad3ed36d..2bd856b2 100644 --- a/app/src/test/java/dev/blazelight/p4oc/ui/tabs/TabManagerPersistenceTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/ui/tabs/TabManagerPersistenceTest.kt @@ -8,6 +8,7 @@ import dev.blazelight.p4oc.domain.server.WorkspaceKey import dev.blazelight.p4oc.ui.navigation.Screen import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -26,7 +27,7 @@ class TabManagerPersistenceTest { manager.updateTabWorkspace(tab.id, WorkspaceKey.Directory("/repo/a")) manager.updateTabSession(tab.id, "s1", "Title") - val saved = manager.saveState(server)!! + val saved = manager.saveState()!! assertEquals(PersistedTabState.CURRENT_VERSION, saved.version) assertEquals(server.endpointKey, saved.serverEndpointKey) @@ -261,12 +262,78 @@ class TabManagerPersistenceTest { focus = true, ) - val saved = manager.saveState(server)!! + val saved = manager.saveState()!! assertEquals(1, saved.tabs.size) assertEquals(Screen.Files.route, saved.tabs.single().startRoute) } + @Test + fun `saveState returns null when pinned Home is the only tab`() { + val manager = TabManager() + manager.ensureHomeTab(focus = true) + + assertNull(manager.saveState()) + } + + @Test + fun `saveState preserves each work tab server instead of using one server fallback`() { + val alpha = ServerRef.fromEndpointKey("http://alpha.example:4096") + val beta = ServerRef.fromEndpointKey("http://beta.example:4096") + val manager = TabManager() + manager.createTab( + startRoute = Screen.Files.route, + workspaceKey = WorkspaceKey.Directory("/alpha"), + serverRef = alpha, + focus = false, + ) + manager.createTab( + startRoute = Screen.Sessions.route, + workspaceKey = WorkspaceKey.Directory("/beta"), + serverRef = beta, + focus = true, + ) + + val saved = manager.saveState()!! + + assertEquals( + listOf(alpha.endpointKey, beta.endpointKey), + saved.tabs.map { it.serverEndpointKey }, + ) + assertEquals(listOf("/alpha", "/beta"), saved.tabs.map { it.workspaceKey?.value }) + } + + @Test + fun `saveState drops ownerless work tabs without borrowing another tab owner`() { + val manager = TabManager() + val owned = manager.createTab( + startRoute = Screen.Files.route, + workspaceKey = WorkspaceKey.Directory("/owned"), + serverRef = server, + focus = false, + ) + manager.registerTab( + TabInstance( + state = TabState(workspaceKey = WorkspaceKey.Directory("/missing-server")), + startRoute = Screen.Files.route, + ), + focus = false, + ) + manager.registerTab( + TabInstance( + state = TabState(serverRef = server), + startRoute = Screen.Sessions.route, + ), + focus = true, + ) + + val saved = manager.saveState()!! + + assertEquals(listOf(owned.id), saved.tabs.map { it.id }) + assertEquals(server.endpointKey, saved.tabs.single().serverEndpointKey) + assertEquals("/owned", saved.tabs.single().workspaceKey?.value) + } + @Test fun `app restart restores Home plus Alpha chat Beta files and Local terminal safely`() { val alpha = ServerRef.fromEndpointKey("http://alpha.example:4096") @@ -293,7 +360,7 @@ class TabManagerPersistenceTest { serverRef = local, focus = true, ) - val persisted = beforeRestart.saveState(alpha)!! + val persisted = beforeRestart.saveState()!! val afterRestart = TabManager() val result = afterRestart.restoreState( @@ -372,7 +439,7 @@ class TabManagerPersistenceTest { focus = true, ) - val saved = manager.saveState(server)!! + val saved = manager.saveState()!! assertEquals(Screen.Sessions.route, saved.tabs.single().startRoute) } From 3e0bce7724ccffa0b0b44768d264a08144aa37b6 Mon Sep 17 00:00:00 2001 From: Jasmin Le Roux Date: Fri, 17 Jul 2026 18:55:49 +0200 Subject: [PATCH 21/22] snapshot --- .../p4oc/ui/screens/home/HomeScreen.kt | 586 +++++++++--------- .../p4oc/ui/screens/server/ServerScreen.kt | 10 +- .../p4oc/ui/screens/server/ServerViewModel.kt | 25 +- .../blazelight/p4oc/ui/tabs/MainTabScreen.kt | 292 ++++++--- .../p4oc/ui/tabs/StartWorkContext.kt | 31 + .../dev/blazelight/p4oc/ui/theme/Sizing.kt | 1 + .../p4oc/ui/theme/opencode/FallbackTheme.kt | 5 +- app/src/main/res/values/strings.xml | 2 + .../ui/screens/home/HomeSummaryBuilderTest.kt | 58 +- .../p4oc/ui/tabs/StartWorkContextTest.kt | 96 +++ .../brainstorm.md | 12 + .../panelist-landscape.md | 3 + .../synthesis.md | 38 ++ .../tree.md | 36 ++ 14 files changed, 820 insertions(+), 375 deletions(-) create mode 100644 docs/brainstorms/2026-07-15-server-filter-mini-cards/brainstorm.md create mode 100644 docs/brainstorms/2026-07-15-server-filter-mini-cards/panelist-landscape.md create mode 100644 docs/brainstorms/2026-07-15-server-filter-mini-cards/synthesis.md create mode 100644 docs/brainstorms/2026-07-15-server-filter-mini-cards/tree.md diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/home/HomeScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/home/HomeScreen.kt index f89b09e6..eed19d7f 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/home/HomeScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/home/HomeScreen.kt @@ -8,8 +8,11 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -20,8 +23,10 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons @@ -45,6 +50,9 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.style.TextOverflow import dev.blazelight.p4oc.core.network.ConnectionState import dev.blazelight.p4oc.domain.model.SessionPresence @@ -61,9 +69,6 @@ import java.util.concurrent.TimeUnit private const val RECENT_DAY_LIMIT = 30 private const val HOME_WORKSPACE_SHORTCUT_LIMIT = 3 -private const val PERSISTENT_SERVER_CARD_LIMIT = 3 -private const val ALL_SERVERS_CARD_WEIGHT = 0.72f - data class HomeActions( val onBrowseSessions: (StartWorkTarget) -> Unit, val onBrowseAllSessions: () -> Unit = {}, @@ -80,9 +85,10 @@ data class HomeActions( private data class HomeOverviewInput( val summary: HomeSummaryState, - val filterEndpointKey: String?, + val enabledEndpointKeys: Set, val searchQuery: String, - val onFilter: (String?) -> Unit, + val onToggleServer: (String) -> Unit, + val onEnableAllServers: () -> Unit, val onSearchQueryChange: (String) -> Unit, val showAllWorkspaces: Boolean, val onShowAllWorkspacesChange: (Boolean) -> Unit, @@ -99,48 +105,19 @@ private data class WorkspaceDetailInput( val onBack: () -> Unit, ) -private data class ServerFilterHeaderState( - val active: ServerSummary?, - val totalCount: Int, - val allSelected: Boolean, - val expandable: Boolean, - val expanded: Boolean, -) - @Composable +@Suppress("LongMethod") fun homeScreen( summary: HomeSummaryState, actions: HomeActions, modifier: Modifier = Modifier, ) { var selectedWorkspace by remember { mutableStateOf(null) } - var filterEndpointKey by remember { mutableStateOf(null) } + var disabledEndpointKeys by rememberSaveable { mutableStateOf(emptyList()) } var searchQuery by rememberSaveable { mutableStateOf("") } var showAllWorkspaces by rememberSaveable { mutableStateOf(false) } val selected = selectedWorkspace - if (selected == null) { - homeOverview( - input = HomeOverviewInput( - summary = summary, - filterEndpointKey = filterEndpointKey, - searchQuery = searchQuery, - onFilter = { filterEndpointKey = it }, - onSearchQueryChange = { searchQuery = it }, - showAllWorkspaces = showAllWorkspaces, - onShowAllWorkspacesChange = { showAllWorkspaces = it }, - onWorkspaceClick = { - selectedWorkspace = it - actions.onWorkspaceSelected(it) - actions.onWorkspaceDetailChanged( - StartWorkSelection.Selected(StartWorkTarget(it.serverRef, it.workspaceKey)), - ) - }, - actions = actions, - listState = rememberLazyListState(), - ), - modifier = modifier, - ) - } else { + if (selected != null) { workspaceDetail( input = WorkspaceDetailInput( workspace = selected, @@ -160,14 +137,79 @@ fun homeScreen( ), modifier = modifier, ) + } else if (showAllWorkspaces) { + allWorkspacesScreen( + workspaces = summary.filteredHomeResults( + enabledEndpointKeys = summary.enabledEndpointKeys(disabledEndpointKeys), + query = "", + ).workspaces, + onWorkspaceClick = { + selectedWorkspace = it + actions.onWorkspaceSelected(it) + }, + onBack = { showAllWorkspaces = false }, + modifier = modifier, + ) + } else { + homeOverview( + input = HomeOverviewInput( + summary = summary, + enabledEndpointKeys = summary.enabledEndpointKeys(disabledEndpointKeys), + searchQuery = searchQuery, + onToggleServer = { endpointKey -> + disabledEndpointKeys = disabledEndpointKeys.toggleMembership(endpointKey) + }, + onEnableAllServers = { disabledEndpointKeys = emptyList() }, + onSearchQueryChange = { searchQuery = it }, + showAllWorkspaces = showAllWorkspaces, + onShowAllWorkspacesChange = { showAllWorkspaces = it }, + onWorkspaceClick = { + selectedWorkspace = it + actions.onWorkspaceSelected(it) + actions.onWorkspaceDetailChanged( + StartWorkSelection.Selected(StartWorkTarget(it.serverRef, it.workspaceKey)), + ) + }, + actions = actions, + listState = rememberLazyListState(), + ), + modifier = modifier, + ) + } +} + +@Composable +private fun allWorkspacesScreen( + workspaces: List, + onWorkspaceClick: (WorkspaceSummary) -> Unit, + onBack: () -> Unit, + modifier: Modifier, +) { + LazyColumn( + modifier = modifier.fillMaxSize().testTag("home_all_workspaces"), + contentPadding = androidx.compose.foundation.layout.PaddingValues(Spacing.md), + verticalArrangement = Arrangement.spacedBy(Spacing.xs), + ) { + item { textAction("‹ Home", "Back to recent workspaces", onBack) } + item { sectionLabel("All workspaces · ${workspaces.size}") } + item { + Column( + Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(Spacing.xxs), + ) { + workspaces.forEach { workspace -> + workspaceShortcut(workspace, onWorkspaceClick, Modifier.fillMaxWidth()) + } + } + } } } @Composable private fun homeOverview(input: HomeOverviewInput, modifier: Modifier) { val summary = input.summary - val results by remember(summary, input.filterEndpointKey, input.searchQuery) { - derivedStateOf { summary.filteredHomeResults(input.filterEndpointKey, input.searchQuery) } + val results by remember(summary, input.enabledEndpointKeys, input.searchQuery) { + derivedStateOf { summary.filteredHomeResults(input.enabledEndpointKeys, input.searchQuery) } } LazyColumn( state = input.listState, @@ -186,11 +228,18 @@ private fun LazyListScope.homeOverviewContent( val summary = input.summary item { homeHeader(summary, input.actions.onManageServers) } item { homeSearchField(input.searchQuery, input.onSearchQueryChange) } - item { serverFilters(summary.servers, input.filterEndpointKey, input.onFilter) } - if (input.searchQuery.isNotBlank() && input.filterEndpointKey != null) { + item { + serverFilters( + servers = summary.servers, + enabledEndpointKeys = input.enabledEndpointKeys, + searchActive = input.searchQuery.isNotBlank(), + onToggle = input.onToggleServer, + ) + } + if (input.searchQuery.isNotBlank() && input.enabledEndpointKeys.size < summary.servers.size) { item { Text( - "Search results include every server · clear search to return to the selected server", + globalSearchOverrideLabel(input.enabledEndpointKeys.size), style = MaterialTheme.typography.labelSmall, color = LocalOpenCodeTheme.current.textMuted, maxLines = 2, @@ -205,10 +254,31 @@ private fun LazyListScope.homeOverviewContent( if (summary.partialFailures.isNotEmpty()) { item { infoCard("Some work is unavailable", summary.partialFailures.joinToString(" · ")) } } - homeWorkspaces(input, results.workspaces) - HomeSessions(input, results.sessions) + if (input.searchQuery.isBlank() && input.enabledEndpointKeys.isEmpty()) { + item { + infoCard( + "No servers enabled", + "Turn on one or more servers above to browse existing work.", + ) + } + item { + textAction( + label = "Select all servers", + description = "Include every saved server in Home browsing", + onClick = input.onEnableAllServers, + modifier = Modifier.testTag("home_enable_all_servers"), + ) + } + } else { + homeWorkspaces(input, results.workspaces) + HomeSessions(input, results.sessions) + } } +private fun globalSearchOverrideLabel(enabledCount: Int): String = + "Global search includes every server · clear search to resume " + + "$enabledCount enabled server${if (enabledCount == 1) "" else "s"}" + @Composable private fun homeSearchField(query: String, onQueryChange: (String) -> Unit) { val theme = LocalOpenCodeTheme.current @@ -228,22 +298,37 @@ private fun homeSearchField(query: String, onQueryChange: (String) -> Unit) { Modifier.fillMaxSize().padding(horizontal = Spacing.sm), verticalAlignment = Alignment.CenterVertically, ) { - if (query.isEmpty()) { - Text( - "/ Search every server, session, or workspace…", - style = MaterialTheme.typography.labelMedium, - color = theme.textMuted, - maxLines = 1, - ) + Text( + "/", + style = MaterialTheme.typography.labelMedium, + color = theme.textMuted, + ) + Spacer(Modifier.width(Spacing.xs)) + Box(Modifier.weight(1f), contentAlignment = Alignment.CenterStart) { + field() + if (query.isEmpty()) { + Text( + "Search every server, session, or workspace…", + style = MaterialTheme.typography.labelMedium, + color = theme.textMuted, + maxLines = 1, + ) + } } - field() } }, ) } private val HomeSessions: LazyListScope.(HomeOverviewInput, List) -> Unit = { input, sessions -> - item { sectionLabel("Newest sessions · all ${sessions.size}") } + item { + val scope = if (input.searchQuery.isNotBlank()) { + "all servers" + } else { + "${input.enabledEndpointKeys.size} server${if (input.enabledEndpointKeys.size == 1) "" else "s"}" + } + sectionLabel("Newest sessions · $scope · ${sessions.size}") + } if (sessions.isEmpty()) { item { infoCard( @@ -300,8 +385,15 @@ private fun LazyListScope.homeWorkspaces(input: HomeOverviewInput, filteredWorks ) } } else if (input.showAllWorkspaces || input.searchQuery.isNotBlank()) { - items(filteredWorkspaces, key = { "${it.serverRef.endpointKey}:${it.workspaceKey}" }) { workspace -> - workspaceShortcut(workspace, input.onWorkspaceClick, Modifier.fillMaxWidth()) + item { + Column( + Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(Spacing.xxs), + ) { + filteredWorkspaces.forEach { workspace -> + workspaceShortcut(workspace, input.onWorkspaceClick, Modifier.fillMaxWidth()) + } + } } if (input.searchQuery.isBlank()) { item { @@ -371,235 +463,131 @@ private fun homeHeader(summary: HomeSummaryState, onServers: () -> Unit) { @Composable private fun serverFilters( servers: List, - selected: String?, - onSelect: (String?) -> Unit, + enabledEndpointKeys: Set, + searchActive: Boolean, + onToggle: (String) -> Unit, ) { - var expanded by rememberSaveable { mutableStateOf(false) } - val active = servers.firstOrNull { it.serverRef.endpointKey == selected } - val useCompactSelector = servers.size > PERSISTENT_SERVER_CARD_LIMIT Column( Modifier.fillMaxWidth().testTag("home_server_filters"), verticalArrangement = Arrangement.spacedBy(Spacing.xxs), ) { - if (useCompactSelector) { - serverFilterHeader( - ServerFilterHeaderState( - active = active, - totalCount = servers.sumOf { it.sessionCount }, - allSelected = selected == null, - expandable = true, - expanded = expanded, - ), - ) { expanded = !expanded } - if (expanded) { - expandedServerSelector(servers, selected) { endpointKey -> - onSelect(endpointKey) - expanded = false - } - } - } else { - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(Spacing.xxs)) { - allServersRailCard( - count = servers.sumOf { it.sessionCount }, - selected = selected == null, - modifier = Modifier.weight(ALL_SERVERS_CARD_WEIGHT), - ) { onSelect(null) } - servers.forEach { server -> - serverRailCard( - server = server, - selected = selected == server.serverRef.endpointKey, - modifier = Modifier.weight(1f), - ) { onSelect(server.serverRef.endpointKey) } - } - } - } - } -} - -@Composable -private fun allServersRailCard(count: Int, selected: Boolean, modifier: Modifier, onClick: () -> Unit) { - val theme = LocalOpenCodeTheme.current - Surface( - onClick = onClick, - shape = RectangleShape, - color = if (selected) theme.backgroundElement else theme.backgroundPanel, - modifier = modifier.then( - if (selected) Modifier.border(Sizing.strokeMd, theme.accent, RectangleShape) else Modifier, - ), - ) { - Column(Modifier.fillMaxWidth().padding(horizontal = Spacing.sm, vertical = Spacing.xs)) { - Text( - "All", - style = MaterialTheme.typography.labelMedium, - color = theme.text, - maxLines = 1, - ) - Text( - "$count sessions", - style = MaterialTheme.typography.labelSmall, - color = theme.textMuted, - maxLines = 1, - ) - } - } -} - -@Composable -private fun serverFilterHeader( - state: ServerFilterHeaderState, - onClick: () -> Unit, -) { - val theme = LocalOpenCodeTheme.current - Surface( - onClick = onClick, - shape = RectangleShape, - color = if (state.allSelected || state.active != null) theme.backgroundElement else theme.backgroundPanel, - ) { - Row(Modifier.fillMaxWidth().padding(horizontal = Spacing.sm, vertical = Spacing.xs)) { - Text( - state.active?.displayName ?: "All servers", - style = MaterialTheme.typography.labelMedium, - color = theme.text, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { Text( - "${state.active?.sessionCount ?: state.totalCount}", + "Servers · ${enabledEndpointKeys.size}/${servers.size} on", style = MaterialTheme.typography.labelSmall, - color = theme.textMuted, + color = LocalOpenCodeTheme.current.textMuted, ) - if (state.expandable) { + if (servers.size > 1) { Text( - if (state.expanded) "▴" else "▾", + "Swipe ›", style = MaterialTheme.typography.labelSmall, - color = theme.accent, - modifier = Modifier.padding(start = Spacing.xs), + color = LocalOpenCodeTheme.current.primary, ) } } - } -} - -@Composable -private fun expandedServerSelector( - servers: List, - selected: String?, - onSelect: (String?) -> Unit, -) { - serverSelectorItem("All servers", servers.sumOf { it.sessionCount }, selected == null) { - onSelect(null) - } - servers.forEach { server -> - serverSelectorItem( - label = server.displayName, - count = server.sessionCount, - selected = selected == server.serverRef.endpointKey, - status = server.connectionState.toServerStatus(), - ) { onSelect(server.serverRef.endpointKey) } - } -} - -@Composable -private fun serverSelectorItem( - label: String, - count: Int, - selected: Boolean, - status: ServerConnectionStatus? = null, - onClick: () -> Unit, -) { - val theme = LocalOpenCodeTheme.current - Surface( - onClick = onClick, - shape = RectangleShape, - color = if (selected) theme.backgroundElement else theme.backgroundPanel, - modifier = if (selected) Modifier.border(Sizing.strokeMd, theme.accent, RectangleShape) else Modifier, - ) { - Row( - Modifier.fillMaxWidth().padding(horizontal = Spacing.sm, vertical = Spacing.xs), - verticalAlignment = Alignment.CenterVertically, + LazyRow( horizontalArrangement = Arrangement.spacedBy(Spacing.xs), + contentPadding = PaddingValues(end = Spacing.xl), + modifier = Modifier.fillMaxWidth(), ) { - if (status != null) { - Box(Modifier.size(Sizing.indicatorDot).background(status.dotColor(theme), CircleShape)) + items(servers, key = { it.serverRef.endpointKey }) { server -> + serverToggleCard( + server = server, + enabled = server.serverRef.endpointKey in enabledEndpointKeys, + searchActive = searchActive, + onToggle = { onToggle(server.serverRef.endpointKey) }, + ) } - Text( - label, - style = MaterialTheme.typography.labelMedium, - color = theme.text, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - Text("$count", style = MaterialTheme.typography.labelSmall, color = theme.textMuted) } } } @Composable -private fun serverRailCard( +private fun serverToggleCard( server: ServerSummary, - selected: Boolean, - modifier: Modifier, - onClick: () -> Unit, + enabled: Boolean, + searchActive: Boolean, + onToggle: () -> Unit, ) { val theme = LocalOpenCodeTheme.current + val status = server.connectionState.toServerStatus() + val accessibilityDescription = remember(server, status) { + "${server.displayName}, ${status.visualLabel}, ${server.sessionCount} sessions, " + + serverEndpointDetail(server) + } Surface( - onClick = onClick, shape = RectangleShape, - color = if (selected) theme.backgroundElement else theme.backgroundPanel, - modifier = modifier.then( - if (selected) Modifier.border(Sizing.strokeMd, theme.accent, RectangleShape) else Modifier, - ), - ) { - Row( - Modifier.fillMaxWidth().padding(horizontal = Spacing.sm, vertical = Spacing.xs), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - Modifier - .width(Sizing.strokeThick) - .height(Sizing.buttonHeightMd) - .background(ProjectColors.colorForProject("server:${server.serverRef.endpointKey}")), + color = theme.backgroundPanel, + modifier = Modifier + .width(Sizing.serverFilterCardWidth) + .then( + if (enabled) Modifier.border(Sizing.strokeMd, theme.primary, RectangleShape) else Modifier, ) - Spacer(Modifier.width(Spacing.xs)) - serverRailCardContent(server, theme, Modifier.weight(1f)) - } + .toggleable( + value = enabled, + role = Role.Checkbox, + onValueChange = { if (!searchActive) onToggle() }, + ) + .semantics { + stateDescription = if (searchActive) { + "${if (enabled) "On" else "Off"}; saved filter paused during global search" + } else if (enabled) { + "On" + } else { + "Off" + } + contentDescription = accessibilityDescription + } + .testTag("home_server_toggle_${server.serverRef.endpointKey}"), + ) { + serverToggleCardContent(server, status, enabled, theme) } } @Composable -private fun serverRailCardContent(server: ServerSummary, theme: OpenCodeTheme, modifier: Modifier = Modifier) { - Column(modifier) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.xs)) { +private fun serverToggleCardContent( + server: ServerSummary, + status: ServerConnectionStatus, + enabled: Boolean, + theme: OpenCodeTheme, +) { + Column(Modifier.padding(horizontal = Spacing.sm, vertical = Spacing.xs)) { + Row(verticalAlignment = Alignment.CenterVertically) { Box( Modifier.size(Sizing.indicatorDot) - .background(server.connectionState.toServerStatus().dotColor(theme), CircleShape), + .background(status.dotColor(theme), CircleShape) + .semantics { contentDescription = status.contentDescription }, ) + Spacer(Modifier.width(Spacing.xs)) Text( server.displayName, style = MaterialTheme.typography.labelMedium, - color = theme.text, + color = if (enabled) theme.text else theme.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Text( + if (enabled) "ON" else "OFF", + style = MaterialTheme.typography.labelSmall, + color = if (enabled) theme.primary else theme.textMuted, ) } - val endpointDetail = serverEndpointDetail(server) - if (endpointDetail != server.displayName) { + Row { Text( - endpointDetail, + serverEndpointDetail(server), style = MaterialTheme.typography.labelSmall, color = theme.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), ) + Text("${server.sessionCount}", style = MaterialTheme.typography.labelSmall, color = theme.textMuted) } - Text( - "${server.sessionCount} sessions", - style = MaterialTheme.typography.labelSmall, - color = theme.textMuted, - maxLines = 1, - ) } } @@ -684,55 +672,70 @@ private fun sessionRow( modifier = Modifier.fillMaxWidth(), ) { Row( - Modifier.padding(horizontal = Spacing.sm, vertical = Spacing.xs), - horizontalArrangement = Arrangement.spacedBy(Spacing.sm), + Modifier.fillMaxWidth().height(IntrinsicSize.Min), verticalAlignment = Alignment.CenterVertically, ) { - Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(Spacing.xxs)) { - Text( - session.title, - style = MaterialTheme.typography.bodyMedium, - color = theme.text, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Row( - horizontalArrangement = Arrangement.spacedBy(Spacing.sm), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - "● ${session.status.label()}", - style = MaterialTheme.typography.labelSmall, - color = session.status.statusColor(theme), - ) + Box( + Modifier + .width(Sizing.strokeThick) + .fillMaxHeight() + .background(ProjectColors.colorForProject("server:${session.serverRef.endpointKey}")), + ) + Row( + Modifier.weight(1f).padding(horizontal = Spacing.sm, vertical = Spacing.xs), + horizontalArrangement = Arrangement.spacedBy(Spacing.sm), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(Spacing.xxs)) { Text( - recency(session.updatedAt), - style = MaterialTheme.typography.labelSmall, - color = theme.textMuted, + session.title, + style = MaterialTheme.typography.bodyMedium, + color = theme.text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) - if (session.childCount > 0) { + Row( + horizontalArrangement = Arrangement.spacedBy(Spacing.sm), + verticalAlignment = Alignment.CenterVertically, + ) { Text( - "[${session.childCount} sub]", + "● ${session.status.label()}", style = MaterialTheme.typography.labelSmall, - color = theme.info, + color = session.status.statusColor(theme), ) - } - if (session.additions > 0) { Text( - "+${session.additions}", + recency(session.updatedAt), style = MaterialTheme.typography.labelSmall, - color = theme.success, + color = theme.textMuted, ) - } - if (session.deletions > 0) { - Text("-${session.deletions}", style = MaterialTheme.typography.labelSmall, color = theme.error) - } - if (session.isShared) { - Text("◈ Shared", style = MaterialTheme.typography.labelSmall, color = theme.info) + if (session.childCount > 0) { + Text( + "[${session.childCount} sub]", + style = MaterialTheme.typography.labelSmall, + color = theme.info, + ) + } + if (session.additions > 0) { + Text( + "+${session.additions}", + style = MaterialTheme.typography.labelSmall, + color = theme.success, + ) + } + if (session.deletions > 0) { + Text( + "-${session.deletions}", + style = MaterialTheme.typography.labelSmall, + color = theme.error, + ) + } + if (session.isShared) { + Text("◈ Shared", style = MaterialTheme.typography.labelSmall, color = theme.info) + } } } + SessionWorkspaceLabel(session, onWorkspace) } - SessionWorkspaceLabel(session, onWorkspace) } } } @@ -778,6 +781,24 @@ private fun ServerConnectionStatus.dotColor( ServerConnectionStatus.ERROR -> theme.error } +private val ServerConnectionStatus.contentDescription: String + get() = when (this) { + ServerConnectionStatus.CONNECTED -> "Connected server" + ServerConnectionStatus.CONNECTING -> "Server connecting" + ServerConnectionStatus.AVAILABLE -> "Server available" + ServerConnectionStatus.DISCONNECTED -> "Server disconnected" + ServerConnectionStatus.ERROR -> "Server connection error" + } + +private val ServerConnectionStatus.visualLabel: String + get() = when (this) { + ServerConnectionStatus.CONNECTED -> "online" + ServerConnectionStatus.CONNECTING -> "connecting" + ServerConnectionStatus.AVAILABLE -> "available" + ServerConnectionStatus.DISCONNECTED -> "offline" + ServerConnectionStatus.ERROR -> "error" + } + @Composable private fun workspaceDetail( input: WorkspaceDetailInput, @@ -924,24 +945,35 @@ internal data class FilteredHomeResults( val sessions: List, ) -/** Applies browse scope when idle; a query searches every saved server. */ -internal fun HomeSummaryState.filteredHomeResults(endpointKey: String?, query: String): FilteredHomeResults { +/** Applies enabled server filters when idle; a query searches every saved server. */ +internal fun HomeSummaryState.filteredHomeResults( + enabledEndpointKeys: Set, + query: String, +): FilteredHomeResults { val needle = query.trim() - val browseEndpointKey = endpointKey.takeIf { needle.isEmpty() } + val applyBrowseFilter = needle.isEmpty() val matchingWorkspaces = ArrayList(workspaces.size) for (workspace in workspaces) { - if (browseEndpointKey != null && workspace.serverRef.endpointKey != browseEndpointKey) continue + if (applyBrowseFilter && workspace.serverRef.endpointKey !in enabledEndpointKeys) continue if (needle.isEmpty() || workspace.matchesSearch(needle)) matchingWorkspaces += workspace } val matchingSessions = ArrayList(sessions.size) for (session in sessions) { - if (browseEndpointKey != null && session.serverRef.endpointKey != browseEndpointKey) continue + if (applyBrowseFilter && session.serverRef.endpointKey !in enabledEndpointKeys) continue if (needle.isEmpty() || session.matchesSearch(needle)) matchingSessions += session } matchingSessions.sortByDescending { it.updatedAt } return FilteredHomeResults(matchingWorkspaces, matchingSessions) } +private fun HomeSummaryState.enabledEndpointKeys(disabledEndpointKeys: Collection): Set = + servers.mapTo(LinkedHashSet(servers.size)) { it.serverRef.endpointKey }.apply { + removeAll(disabledEndpointKeys) + } + +private fun List.toggleMembership(value: String): List = + if (value in this) this - value else this + value + private fun WorkspaceSummary.matchesSearch(query: String): Boolean = serverRef.displayName.contains(query, ignoreCase = true) || workspaceKey.displayLabel().contains(query, ignoreCase = true) || diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerScreen.kt index 6328fddd..ba16b69d 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerScreen.kt @@ -35,6 +35,8 @@ import dev.blazelight.p4oc.R import dev.blazelight.p4oc.core.datastore.SavedServer import dev.blazelight.p4oc.core.network.DiscoveredServer import dev.blazelight.p4oc.core.network.DiscoveryState +import dev.blazelight.p4oc.core.network.ServerConnectionRegistry +import dev.blazelight.p4oc.core.network.toServerRef import dev.blazelight.p4oc.ui.components.TuiConfirmDialog import dev.blazelight.p4oc.ui.components.TuiLoadingIndicator import dev.blazelight.p4oc.ui.components.status.serverStatusIndicator @@ -46,6 +48,7 @@ import org.koin.androidx.compose.koinViewModel import org.koin.compose.koinInject @OptIn(ExperimentalMaterial3Api::class) +@Suppress("LongMethod") @Composable fun serverScreen( onNavigateToSessions: () -> Unit, @@ -57,10 +60,15 @@ fun serverScreen( val viewModel: ServerViewModel = koinViewModel() val theme = LocalOpenCodeTheme.current val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val serverConnectionRegistry: ServerConnectionRegistry = koinInject() + val registryStates = uiState.savedServers.associate { saved -> + val state by serverConnectionRegistry.connectionState(saved.toServerRef()).collectAsStateWithLifecycle() + saved.endpointKey to state + } val tabManager: TabManager = koinInject() val tabs by tabManager.tabs.collectAsState() val openTabsByEndpoint = tabs.filterNot { it.isPinnedHome }.groupBy { it.serverEndpointKey } - val inventory = remember(uiState) { buildServerInventory(uiState) } + val inventory = remember(uiState, registryStates) { buildServerInventory(uiState, registryStates) } var showManualForm by rememberSaveable { mutableStateOf(uiState.savedServers.isEmpty() && uiState.discoveredServers.isEmpty()) } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerViewModel.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerViewModel.kt index 1277a909..a1497291 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerViewModel.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerViewModel.kt @@ -7,6 +7,7 @@ import dev.blazelight.p4oc.core.datastore.SavedServer import dev.blazelight.p4oc.core.datastore.SettingsDataStore import dev.blazelight.p4oc.core.log.AppLog import dev.blazelight.p4oc.core.network.ConnectionManager +import dev.blazelight.p4oc.core.network.ConnectionState import dev.blazelight.p4oc.core.network.DiscoveredServer import dev.blazelight.p4oc.core.network.DiscoverySeed import dev.blazelight.p4oc.core.network.DiscoveryState @@ -34,18 +35,28 @@ data class ServerInventory( val nearby: List, ) -internal fun buildServerInventory(state: ServerUiState): ServerInventory { +internal fun buildServerInventory( + state: ServerUiState, + connectionStates: Map = emptyMap(), +): ServerInventory { val discoveredByEndpoint = state.discoveredServers.associateBy { ServerUrl.endpointKey(it.url) ?: it.url.trim() } val saved = state.savedServers.distinctBy(SavedServer::endpointKey).map { server -> val discovered = discoveredByEndpoint[server.endpointKey] - val status = when { - state.connectedEndpointKey == server.endpointKey && state.isConnected -> ServerConnectionStatus.CONNECTED - state.connectingEndpointKey == server.endpointKey && state.isConnecting -> ServerConnectionStatus.CONNECTING - state.failedEndpointKey == server.endpointKey -> ServerConnectionStatus.ERROR - discovered != null -> ServerConnectionStatus.AVAILABLE - else -> ServerConnectionStatus.DISCONNECTED + val status = when (connectionStates[server.endpointKey]) { + ConnectionState.Connected -> ServerConnectionStatus.CONNECTED + ConnectionState.Connecting -> ServerConnectionStatus.CONNECTING + is ConnectionState.Error -> ServerConnectionStatus.ERROR + ConnectionState.Disconnected, null -> when { + state.connectedEndpointKey == server.endpointKey && state.isConnected -> + ServerConnectionStatus.CONNECTED + state.connectingEndpointKey == server.endpointKey && state.isConnecting -> + ServerConnectionStatus.CONNECTING + state.failedEndpointKey == server.endpointKey -> ServerConnectionStatus.ERROR + discovered != null -> ServerConnectionStatus.AVAILABLE + else -> ServerConnectionStatus.DISCONNECTED + } } ServerInventoryEntry(server, discovered, status) } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/MainTabScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/MainTabScreen.kt index b15761e6..47be710e 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/MainTabScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/MainTabScreen.kt @@ -2,6 +2,7 @@ package dev.blazelight.p4oc.ui.tabs +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -18,9 +19,15 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet @@ -31,6 +38,7 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -45,11 +53,14 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.lifecycle.Lifecycle @@ -85,6 +96,7 @@ import dev.blazelight.p4oc.ui.screens.home.HomeSummaryInput import dev.blazelight.p4oc.ui.screens.home.ScopedHomeRepositoryState import dev.blazelight.p4oc.ui.screens.home.homeScreen import dev.blazelight.p4oc.ui.theme.LocalOpenCodeTheme +import dev.blazelight.p4oc.ui.theme.Sizing import dev.blazelight.p4oc.ui.theme.Spacing import dev.blazelight.p4oc.ui.theme.TuiShapes import dev.blazelight.p4oc.ui.workspace.WorkspaceRepositoryOwner @@ -117,7 +129,133 @@ private class StartWorkUiState { var showStartWorkPicker: Boolean by mutableStateOf(false) var homeDetailSelection: StartWorkSelection by mutableStateOf(StartWorkSelection.NeedsSelection) var pendingStartWork: Pair? by mutableStateOf(null) - var collapsedPickerServers: Set by mutableStateOf(emptySet()) + var pickerSelectedEndpointKey: String? by mutableStateOf(null) + var pickerSearchQuery: String by mutableStateOf("") +} + +private val startWorkPickerSearch: @Composable (StartWorkUiState) -> Unit = { uiState -> + val theme = LocalOpenCodeTheme.current + BasicTextField( + value = uiState.pickerSearchQuery, + onValueChange = { uiState.pickerSearchQuery = it }, + singleLine = true, + textStyle = MaterialTheme.typography.bodyMedium.copy(color = theme.text), + cursorBrush = SolidColor(theme.accent), + modifier = Modifier + .fillMaxWidth() + .border(Sizing.strokeThin, theme.border, RectangleShape) + .testTag("start_work_search_field"), + decorationBox = { field -> + Row( + Modifier.fillMaxWidth().padding(horizontal = Spacing.sm, vertical = Spacing.xs), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("/", style = MaterialTheme.typography.labelMedium, color = theme.textMuted) + Spacer(Modifier.width(Spacing.xs)) + Box(Modifier.weight(1f), contentAlignment = Alignment.CenterStart) { + field() + if (uiState.pickerSearchQuery.isEmpty()) { + Text( + stringResource(R.string.start_work_filter_workspaces), + style = MaterialTheme.typography.labelMedium, + color = theme.textMuted, + maxLines = 1, + ) + } + } + } + }, + ) +} + +private val startWorkServerRail: @Composable ( + List, + StartWorkPickerGroup?, + StartWorkUiState, +) -> Unit = { groups, selectedGroup, uiState -> + val theme = LocalOpenCodeTheme.current + LazyRow(horizontalArrangement = Arrangement.spacedBy(Spacing.xs)) { + items(groups, key = { it.server.endpointKey }) { group -> + val selected = group.server.endpointKey == selectedGroup?.server?.endpointKey + Surface( + color = theme.backgroundPanel, + shape = RectangleShape, + modifier = Modifier + .width(Sizing.serverFilterCardWidth) + .then(if (selected) Modifier.border(Sizing.strokeMd, theme.primary) else Modifier) + .clickable(role = Role.Tab) { + uiState.pickerSelectedEndpointKey = group.server.endpointKey + uiState.pickerSearchQuery = "" + } + .semantics { + contentDescription = "${group.server.displayName}, ${group.targets.size - 1} workspaces" + this.selected = selected + } + .testTag("start_work_server_${group.server.endpointKey}"), + ) { + Column( + Modifier.padding(horizontal = Spacing.sm, vertical = Spacing.xs), + ) { + Text( + group.badgeLabel, + style = MaterialTheme.typography.labelMedium, + color = if (selected) theme.primary else theme.text, + ) + Text( + group.server.displayName, + style = MaterialTheme.typography.labelSmall, + color = theme.textMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } +} + +private val startWorkPickerLedger: @Composable ColumnScope.( + MainTabContentParams, + List, +) -> Unit = { params, targets -> + val labels = rememberTabTitleLabels() + targets.firstOrNull { it.workspaceKey == WorkspaceKey.Global }?.let { globalTarget -> + filesWorkspaceOption( + title = stringResource(R.string.sessions_global), + subtitle = workspaceSubtitle(globalTarget.workspaceKey), + marker = "◆", + onClick = { selectStartWorkPickerTarget(params, globalTarget) }, + modifier = Modifier.testTag("start_work_target_global"), + ) + } + val directories = targets.filter { it.workspaceKey != WorkspaceKey.Global } + if (directories.isEmpty()) { + Text( + stringResource(R.string.start_work_no_matching_workspaces), + style = MaterialTheme.typography.labelMedium, + color = LocalOpenCodeTheme.current.textMuted, + modifier = Modifier.padding(vertical = Spacing.md), + ) + } else { + LazyColumn( + Modifier.fillMaxWidth().weight(1f, fill = false), + verticalArrangement = Arrangement.spacedBy(Spacing.hairline), + ) { + items( + items = directories, + key = { target -> "target:${target.serverRef.endpointKey}:${target.workspaceKey}" }, + ) { target -> + filesWorkspaceOption( + title = workspaceLabel(target.workspaceKey, labels) ?: workspaceSubtitle(target.workspaceKey), + subtitle = workspaceSubtitle(target.workspaceKey), + marker = "◇", + onClick = { selectStartWorkPickerTarget(params, target) }, + modifier = Modifier.testTag("start_work_target_${target.workspaceKey}"), + ) + } + item(key = "picker_navigation_bar") { Spacer(Modifier.navigationBarsPadding()) } + } + } } internal data class StartWorkPickerGroup( @@ -165,6 +303,7 @@ private data class MainTabContentParams( val savedServers: List, val savedServerViews: List, val scopedConnectionStates: Map, + val homeRepositoryStates: List, val closeTab: (String) -> Unit, val savedServerExists: (String) -> Boolean, val connectSavedServer: (String) -> Unit, @@ -575,6 +714,13 @@ object MainTabScreen { ) mainTabPresenceCollection(tabs, activeTabId, tabMaps) + val homeRepositoryStates = tabMaps.workspaceOwners.values + .distinctBy { it.workspace.server.endpointKey to it.workspace.key } + .map { owner -> + val state by owner.sessionRepository.state.collectAsState() + ScopedHomeRepositoryState(owner.workspace.server, state) + } + val closeTab = rememberCloseTab(deps, tabMaps) val snackbarHostState = remember { SnackbarHostState() } mainTabPendingStartWorkEffect(deps, uiState, scopedConnectionStates, snackbarHostState) @@ -589,6 +735,7 @@ object MainTabScreen { savedServers = savedServers, savedServerViews = savedServerViews, scopedConnectionStates = scopedConnectionStates, + homeRepositoryStates = homeRepositoryStates, closeTab = closeTab, savedServerExists = savedServerExists, connectSavedServer = connectSavedServer, @@ -663,12 +810,6 @@ private fun ColumnScope.mainTabPager( pagerState: PagerState, ) { val saveableStateHolder = rememberSaveableStateHolder() - val homeRepositoryStates = params.tabMaps.workspaceOwners.values - .distinctBy { it.workspace.server.endpointKey to it.workspace.key } - .map { owner -> - val state by owner.sessionRepository.state.collectAsState() - ScopedHomeRepositoryState(owner.workspace.server, state) - } HorizontalPager( state = pagerState, modifier = Modifier.weight(1f), @@ -677,7 +818,7 @@ private fun ColumnScope.mainTabPager( ) { pageIndex -> params.tabs.getOrNull(pageIndex)?.let { tab -> saveableStateHolder.SaveableStateProvider(tab.id) { - mainTabPageContent(params, tab, homeRepositoryStates) + mainTabPageContent(params, tab, params.homeRepositoryStates) } } } @@ -877,6 +1018,7 @@ private fun startWorkSheet(params: MainTabContentParams) { val target = context.selectedTarget ModalBottomSheet( onDismissRequest = { uiState.showStartWorkSheet = false }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), containerColor = theme.background, modifier = Modifier.testTag("start_work_sheet"), ) { @@ -891,7 +1033,13 @@ private fun startWorkSheetContent( ) { val theme = LocalOpenCodeTheme.current Column( - Modifier.fillMaxWidth().padding(Spacing.md), + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = Spacing.md) + .padding(top = Spacing.md) + .navigationBarsPadding() + .padding(bottom = Spacing.md), verticalArrangement = Arrangement.spacedBy(Spacing.sm), ) { Text(stringResource(R.string.start_work_title), style = MaterialTheme.typography.titleLarge) @@ -911,7 +1059,6 @@ private fun startWorkSheetContent( requestScopedAction(params, target, StartWorkAction.BrowseSessions) } } - Spacer(Modifier.navigationBarsPadding()) } } @@ -931,7 +1078,16 @@ private fun startWorkSheetTargetCard( Row(verticalAlignment = Alignment.CenterVertically) { Text(stringResource(R.string.start_work_in), color = theme.textMuted) Spacer(Modifier.width(Spacing.sm)) - Text(target.serverRef.displayName, modifier = Modifier.weight(1f)) + val connectionStatus = connectionStatusText( + params.scopedConnectionStates[target.serverRef.endpointKey], + ) + Text( + "${target.serverRef.displayName} · $connectionStatus", + color = theme.textMuted, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) TextButton(onClick = { params.uiState.showStartWorkPicker = true }) { Text(stringResource(R.string.start_work_change)) } @@ -940,10 +1096,6 @@ private fun startWorkSheetTargetCard( workspaceLabel(target.workspaceKey, tabTitleLabels) ?: workspaceSubtitle(target.workspaceKey), ) - Text( - connectionStatusText(params.scopedConnectionStates[target.serverRef.endpointKey]), - color = theme.textMuted, - ) } } } @@ -990,12 +1142,9 @@ private fun startWorkPickerSheet(params: MainTabContentParams) { val workspaceKey = tab.workspaceKey ?: return@mapNotNull null StartWorkTarget(serverRef, workspaceKey) }.distinct() - val knownHomeTargets = params.tabMaps.workspaceOwners.values.flatMap { owner -> - val state by owner.sessionRepository.state.collectAsState() - state.snapshot.sessions.values.map { session -> - StartWorkTarget(owner.workspace.server, session.workspace.key) - } - }.distinct() + val knownHomeTargets = remember(params.homeRepositoryStates) { + deriveStartWorkPickerTargets(params.homeRepositoryStates) + } ModalBottomSheet( onDismissRequest = { uiState.showStartWorkPicker = false @@ -1016,71 +1165,33 @@ private fun startWorkPickerContent( ) { val theme = LocalOpenCodeTheme.current val tabTitleLabels = rememberTabTitleLabels() - Column( - Modifier.fillMaxWidth().padding(Spacing.md), - verticalArrangement = Arrangement.spacedBy(Spacing.sm), - ) { - Text( - stringResource(R.string.start_work_choose_context), - style = MaterialTheme.typography.titleLarge, - ) - if (params.savedServerViews.isEmpty()) { - Text(stringResource(R.string.start_work_no_servers), color = theme.textMuted) - } - val groups = buildStartWorkPickerGroups( + val uiState = params.uiState + val groups = remember(params.savedServerViews, openTargets, knownHomeTargets) { + buildStartWorkPickerGroups( params.savedServerViews.map { Triple(it.endpointKey, it.displayName, it.badgeLabel) }, openTargets, knownHomeTargets, ) - groups.forEach { group -> startWorkPickerGroup(params, group, tabTitleLabels) } - Spacer(Modifier.navigationBarsPadding()) } -} - -private val startWorkPickerGroup: @Composable ( - MainTabContentParams, - StartWorkPickerGroup, - TabTitleLabels, -) -> Unit = { params, group, tabTitleLabels -> - val collapsed = group.server.endpointKey in params.uiState.collapsedPickerServers - Text( - "${if (collapsed) "▸" else "▾"} ${group.badgeLabel} ${group.server.displayName}", - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.fillMaxWidth().clickable(role = Role.Button) { - params.uiState.collapsedPickerServers = if (collapsed) { - params.uiState.collapsedPickerServers - group.server.endpointKey - } else { - params.uiState.collapsedPickerServers + group.server.endpointKey - } - }.semantics { - contentDescription = "${group.server.displayName}, ${if (collapsed) "collapsed" else "expanded"}" - }.testTag("start_work_server_${group.server.endpointKey}"), - ) - if (!collapsed) { - group.targets.forEach { pickedTarget -> - val title = if (pickedTarget.workspaceKey == WorkspaceKey.Global) { - stringResource(R.string.sessions_global) - } else { - workspaceLabel(pickedTarget.workspaceKey, tabTitleLabels) - ?: workspaceSubtitle(pickedTarget.workspaceKey) - } - filesWorkspaceOption( - title = title, - subtitle = pickedTarget.serverRef.displayName, - marker = if (pickedTarget.workspaceKey == WorkspaceKey.Global) "◆" else "◇", - onClick = { - params.uiState.startWorkContext = StartWorkContext( - StartWorkSource.OtherTab, - StartWorkSelection.Selected(pickedTarget), - params.uiState.startWorkContext?.defaultAction, - ) - params.uiState.homeDetailSelection = StartWorkSelection.Selected(pickedTarget) - params.uiState.showStartWorkPicker = false - params.uiState.showFilesTabPrompt = false - params.uiState.showStartWorkSheet = true - }, - ) + LaunchedEffect(groups, uiState.pickerSelectedEndpointKey) { + if (groups.none { it.server.endpointKey == uiState.pickerSelectedEndpointKey }) { + uiState.pickerSelectedEndpointKey = groups.firstOrNull()?.server?.endpointKey + } + } + val pickerState = StartWorkPickerState(uiState.pickerSelectedEndpointKey, uiState.pickerSearchQuery) + val selectedGroup = groups.firstOrNull { it.server.endpointKey == pickerState.selectedEndpointKey } + val targets = remember(groups, pickerState) { pickerState.filteredTargets(groups) } + Column(Modifier.fillMaxWidth().padding(horizontal = Spacing.md)) { + Text( + stringResource(R.string.start_work_choose_context), + style = MaterialTheme.typography.titleMedium, + ) + if (params.savedServerViews.isEmpty()) { + Text(stringResource(R.string.start_work_no_servers), color = theme.textMuted) } + startWorkPickerSearch(uiState) + startWorkServerRail(groups, selectedGroup, uiState) + startWorkPickerLedger(params, targets) } } @@ -1091,6 +1202,19 @@ internal val createPtyRequestForWorkspace: (WorkspaceKey) -> CreatePtyRequest = ) } +private val selectStartWorkPickerTarget: (MainTabContentParams, StartWorkTarget) -> Unit = + { params, pickedTarget -> + params.uiState.startWorkContext = StartWorkContext( + StartWorkSource.OtherTab, + StartWorkSelection.Selected(pickedTarget), + params.uiState.startWorkContext?.defaultAction, + ) + params.uiState.homeDetailSelection = StartWorkSelection.Selected(pickedTarget) + params.uiState.showStartWorkPicker = false + params.uiState.showFilesTabPrompt = false + params.uiState.showStartWorkSheet = true + } + private val terminalTitle: (WorkspaceKey) -> String? = { workspaceKey -> when (workspaceKey) { is WorkspaceKey.Directory -> workspaceKey.value.trimEnd('/').substringAfterLast('/').ifBlank { "Terminal" } @@ -1147,12 +1271,6 @@ private fun filesWorkspaceOption( .padding(horizontal = Spacing.md, vertical = Spacing.sm), verticalAlignment = Alignment.CenterVertically, ) { - Text( - text = ">", - style = MaterialTheme.typography.bodyMedium, - color = theme.accent.copy(alpha = 0.3f), - ) - Spacer(Modifier.width(Spacing.sm)) Text( text = marker, style = MaterialTheme.typography.bodyMedium, @@ -1182,7 +1300,7 @@ private fun filesWorkspaceOption( Text( text = "→", style = MaterialTheme.typography.bodyMedium, - color = theme.accent, + color = theme.textMuted, ) } } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/StartWorkContext.kt b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/StartWorkContext.kt index 77ae084d..0e93ef8e 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/StartWorkContext.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/StartWorkContext.kt @@ -2,6 +2,7 @@ package dev.blazelight.p4oc.ui.tabs import dev.blazelight.p4oc.domain.server.ServerRef import dev.blazelight.p4oc.domain.server.WorkspaceKey +import dev.blazelight.p4oc.ui.screens.home.ScopedHomeRepositoryState data class StartWorkTarget( val serverRef: ServerRef, @@ -118,6 +119,36 @@ fun startWorkContextForHomeDetail(target: StartWorkTarget): StartWorkContext = S selection = StartWorkSelection.Selected(target), ) +internal fun deriveStartWorkPickerTargets( + repositories: List, +): List = repositories.flatMap { repository -> + repository.state.snapshot.sessions.values.map { session -> + StartWorkTarget(repository.serverRef, session.workspace.key) + } +}.distinct() + +internal data class StartWorkPickerState( + val selectedEndpointKey: String?, + val query: String = "", +) + +internal fun StartWorkPickerState.filteredTargets( + groups: List, +): List { + val group = groups.firstOrNull { it.server.endpointKey == selectedEndpointKey } ?: return emptyList() + val needle = query.trim() + return group.targets.filter { target -> + target.workspaceKey == WorkspaceKey.Global || needle.isEmpty() || + target.workspaceKey.pickerSearchText().contains(needle, ignoreCase = true) + } +} + +private fun WorkspaceKey.pickerSearchText(): String = when (this) { + is WorkspaceKey.Directory -> "$value ${value.trimEnd('/').substringAfterLast('/')}" + WorkspaceKey.Global -> "global server root no directory" + is WorkspaceKey.SessionScoped -> sessionId.value +} + private fun sourceForRoute(route: String): StartWorkSource = when { route.startsWith("chat/") -> StartWorkSource.ChatTab route.startsWith("files") -> StartWorkSource.FilesTab diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/theme/Sizing.kt b/app/src/main/java/dev/blazelight/p4oc/ui/theme/Sizing.kt index 6154e9ce..e1ef5cf2 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/theme/Sizing.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/theme/Sizing.kt @@ -67,6 +67,7 @@ object Sizing { val panelWidthSm: Dp = 80.dp val panelWidthMd: Dp = 120.dp val panelWidthLg: Dp = 180.dp + val serverFilterCardWidth: Dp = 104.dp // Scrollable embedded content (e.g. inline full-text blocks) val embeddedScrollMaxHeight: Dp = 360.dp diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/theme/opencode/FallbackTheme.kt b/app/src/main/java/dev/blazelight/p4oc/ui/theme/opencode/FallbackTheme.kt index 5720cc2f..b3de7d8d 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/theme/opencode/FallbackTheme.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/theme/opencode/FallbackTheme.kt @@ -2,6 +2,9 @@ package dev.blazelight.p4oc.ui.theme.opencode import androidx.compose.ui.graphics.Color +@Suppress("MagicNumber") +private val LightTextMuted = Color(0xFF52556D) + fun createFallbackTheme(isDark: Boolean): OpenCodeTheme { return if (isDark) { OpenCodeTheme( @@ -67,7 +70,7 @@ fun createFallbackTheme(isDark: Boolean): OpenCodeTheme { accent = Color(0xFFEA76CB), text = Color(0xFF4C4F69), // Keep small secondary text safely above AA across every light surface. - textMuted = Color(0xFF52556D), + textMuted = LightTextMuted, background = Color(0xFFEFF1F5), error = Color(0xFFD20F39), warning = Color(0xFFDF8E1D), diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index dd3e1a0c..fe412465 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -851,6 +851,8 @@ In Change Choose a server and workspace + Filter workspaces… + No matching workspaces No servers configured. Add a server to start work. New chat Files diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/screens/home/HomeSummaryBuilderTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/screens/home/HomeSummaryBuilderTest.kt index c75eb428..bc2e21c8 100644 --- a/app/src/test/java/dev/blazelight/p4oc/ui/screens/home/HomeSummaryBuilderTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/ui/screens/home/HomeSummaryBuilderTest.kt @@ -291,7 +291,7 @@ class HomeSummaryBuilderTest { ), ) - val filtered = summary.filteredHomeResults(alphaRef.endpointKey, " needle ") + val filtered = summary.filteredHomeResults(setOf(alphaRef.endpointKey), " needle ") assertEquals(listOf("beta", "new", "old"), filtered.sessions.map { it.sessionId.value }) assertEquals( @@ -315,12 +315,66 @@ class HomeSummaryBuilderTest { ) val summary = build(listOf(server), repositories = listOf(repository), workspaceLimit = 2) - val filtered = summary.filteredHomeResults(null, " ") + val filtered = summary.filteredHomeResults(setOf(serverRef.endpointKey), " ") assertEquals(2, filtered.workspaces.size) assertEquals(listOf("three", "two", "one"), filtered.sessions.map { it.sessionId.value }) } + @Test + fun `blank home search combines only enabled servers newest first`() { + val (alpha, alphaRef) = server("http://alpha.example.com", "Alpha") + val (beta, betaRef) = server("http://beta.example.com", "Beta") + val (gamma, gammaRef) = server("http://gamma.example.com", "Gamma") + val summary = build( + servers = listOf(alpha, beta, gamma), + repositories = listOf( + scopedRepository( + alphaRef, + RepoState.Live(snapshot(workspaceSession(alphaRef, "alpha", "/alpha", "Alpha", 20L))), + ), + scopedRepository( + betaRef, + RepoState.Live(snapshot(workspaceSession(betaRef, "beta", "/beta", "Beta", 40L))), + ), + scopedRepository( + gammaRef, + RepoState.Live(snapshot(workspaceSession(gammaRef, "gamma", "/gamma", "Gamma", 60L))), + ), + ), + ) + + val filtered = summary.filteredHomeResults( + enabledEndpointKeys = setOf(alphaRef.endpointKey, gammaRef.endpointKey), + query = "", + ) + + assertEquals(listOf("gamma", "alpha"), filtered.sessions.map { it.sessionId.value }) + assertEquals( + setOf(alphaRef.endpointKey, gammaRef.endpointKey), + filtered.workspaces.map { it.serverRef.endpointKey }.toSet(), + ) + } + + @Test + fun `blank home search with every server off returns no browse results`() { + val (server, serverRef) = server("http://alpha.example.com", "Alpha") + val summary = build( + servers = listOf(server), + repositories = listOf( + scopedRepository( + serverRef, + RepoState.Live(snapshot(workspaceSession(serverRef, "alpha", "/alpha", "Alpha", 20L))), + ), + ), + ) + + val filtered = summary.filteredHomeResults(emptySet(), "") + + assertEquals(emptyList(), filtered.sessions) + assertEquals(emptyList(), filtered.workspaces) + } + private fun build( servers: List, tabs: List = emptyList(), diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/tabs/StartWorkContextTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/tabs/StartWorkContextTest.kt index b5b135f3..cf914302 100644 --- a/app/src/test/java/dev/blazelight/p4oc/ui/tabs/StartWorkContextTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/ui/tabs/StartWorkContextTest.kt @@ -1,8 +1,15 @@ package dev.blazelight.p4oc.ui.tabs +import dev.blazelight.p4oc.data.session.RepoState +import dev.blazelight.p4oc.data.session.Snapshot +import dev.blazelight.p4oc.domain.model.Session import dev.blazelight.p4oc.domain.server.ServerRef import dev.blazelight.p4oc.domain.server.WorkspaceKey +import dev.blazelight.p4oc.domain.session.SessionId +import dev.blazelight.p4oc.domain.session.WorkspaceSession +import dev.blazelight.p4oc.domain.workspace.Workspace import dev.blazelight.p4oc.ui.navigation.Screen +import dev.blazelight.p4oc.ui.screens.home.ScopedHomeRepositoryState import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Assert.assertSame @@ -10,6 +17,26 @@ import org.junit.Assert.assertTrue import org.junit.Test class StartWorkContextTest { + @Test + fun `picker targets derive distinct workspace ownership from repository snapshots`() { + val alpha = ServerRef.fromEndpointKey("http://alpha.test", "Alpha") + val beta = ServerRef.fromEndpointKey("http://beta.test", "Beta") + val repositories = listOf( + scopedRepository(alpha, session(alpha, "a1", "/repo"), session(alpha, "a2", "/repo")), + scopedRepository(beta, session(beta, "b1", "/repo")), + ) + + val targets = deriveStartWorkPickerTargets(repositories) + + assertEquals( + listOf( + StartWorkTarget(alpha, WorkspaceKey.Directory("/repo")), + StartWorkTarget(beta, WorkspaceKey.Directory("/repo")), + ), + targets, + ) + } + private val alpha = ServerRef.fromEndpointKey("http://alpha.example:4096") private val beta = ServerRef.fromEndpointKey("http://beta.example:4096") private val workspace = WorkspaceKey.Directory("/repo") @@ -225,6 +252,49 @@ class StartWorkContextTest { assertTrue(groups[0].targets[1].serverRef != groups[1].targets[1].serverRef) } + @Test + fun `picker filter scopes directories to selected server and pins global`() { + val groups = buildStartWorkPickerGroups( + servers = listOf( + Triple(alpha.endpointKey, "Alpha", "A"), + Triple(beta.endpointKey, "Beta", "B"), + ), + openTargets = listOf( + StartWorkTarget(alpha, WorkspaceKey.Directory("/repo/needle-alpha")), + StartWorkTarget(beta, WorkspaceKey.Directory("/repo/needle-beta")), + ), + knownHomeTargets = emptyList(), + ) + + val filtered = StartWorkPickerState(alpha.endpointKey, "needle").filteredTargets(groups) + + assertEquals(WorkspaceKey.Global, filtered.first().workspaceKey) + assertEquals( + listOf(WorkspaceKey.Global, WorkspaceKey.Directory("/repo/needle-alpha")), + filtered.map { it.workspaceKey }, + ) + assertTrue(filtered.all { it.serverRef.endpointKey == alpha.endpointKey }) + } + + @Test + fun `picker filter matches workspace path case insensitively`() { + val groups = buildStartWorkPickerGroups( + servers = listOf(Triple(alpha.endpointKey, "Alpha", "A")), + openTargets = listOf( + StartWorkTarget(alpha, WorkspaceKey.Directory("/Projects/Android/P4OC")), + StartWorkTarget(alpha, WorkspaceKey.Directory("/Projects/Other")), + ), + knownHomeTargets = emptyList(), + ) + + val filtered = StartWorkPickerState(alpha.endpointKey, "p4oc").filteredTargets(groups) + + assertEquals( + listOf(WorkspaceKey.Global, WorkspaceKey.Directory("/Projects/Android/P4OC")), + filtered.map { it.workspaceKey }, + ) + } + @Test fun `scoped actions retain new chat files terminal order`() { assertEquals( @@ -232,4 +302,30 @@ class StartWorkContextTest { startWorkScopedActionOrder, ) } + + private fun scopedRepository( + serverRef: ServerRef, + vararg sessions: WorkspaceSession, + ) = ScopedHomeRepositoryState( + serverRef, + RepoState.Live(Snapshot(sessions = sessions.associateBy { it.id.value })), + ) + + private fun session( + serverRef: ServerRef, + id: String, + directory: String, + ) = WorkspaceSession( + id = SessionId(id), + workspace = Workspace(serverRef, directory), + session = Session( + id = id, + projectID = "project-$id", + directory = directory, + title = id, + version = "1", + createdAt = 1L, + updatedAt = 1L, + ), + ) } diff --git a/docs/brainstorms/2026-07-15-server-filter-mini-cards/brainstorm.md b/docs/brainstorms/2026-07-15-server-filter-mini-cards/brainstorm.md new file mode 100644 index 00000000..2cf80ba7 --- /dev/null +++ b/docs/brainstorms/2026-07-15-server-filter-mini-cards/brainstorm.md @@ -0,0 +1,12 @@ +# Server filter mini-cards +Date: 2026-07-15 +Stage: UI exploration +Goal: Explore horizontally scrollable, independently toggleable server filter cards for Home +Domain: Android compact TUI UX +Technique: Perspective multiplication + +## Raw Idea +"so what I think i like is a scrollable horizontal row (with ui affordance) and instead of selecting a single server, they're either on or off and can be toggled. gimme some designs for the mini cards. I think I like smth like just [reference image: hollow status circle + archive, second line archive.lan:4096] and maybe then a small indicator for session count or smth?" + +## Context +Home search is global when nonblank. With blank search, enabled server cards form a multi-select browse filter. The row must visibly afford horizontal scrolling, remain compact, preserve endpoint-derived server identity and textual semantics, avoid shadows/rounded Material cards, and scale to roughly five or more servers. The reference favors a two-line terminal-style item with a status mark, display name, and raw endpoint. Session count should remain secondary. A clear empty-selection behavior is required. \ No newline at end of file diff --git a/docs/brainstorms/2026-07-15-server-filter-mini-cards/panelist-landscape.md b/docs/brainstorms/2026-07-15-server-filter-mini-cards/panelist-landscape.md new file mode 100644 index 00000000..68351f85 --- /dev/null +++ b/docs/brainstorms/2026-07-15-server-filter-mini-cards/panelist-landscape.md @@ -0,0 +1,3 @@ +# Pass 1 panelist synthesis + +All three design lenses converged on one key rule: filter inclusion and connection health need independent encodings. Best compact direction is the user's two-line reference with a connection glyph at left, explicit ON/OFF at right, raw endpoint below, and a fixed-width session count. Best accessibility fallback is a three-line checkbox card. Horizontal overflow should use a clipped next card plus position-aware chevrons/hidden count. No All pseudo-card; all-on is simply every toggle enabled. All-off means zero results plus a Select all recovery action. Blank-search results interleave newest-first across enabled servers; global search preserves but temporarily ignores selections with explicit disclosure. \ No newline at end of file diff --git a/docs/brainstorms/2026-07-15-server-filter-mini-cards/synthesis.md b/docs/brainstorms/2026-07-15-server-filter-mini-cards/synthesis.md new file mode 100644 index 00000000..82ec850d --- /dev/null +++ b/docs/brainstorms/2026-07-15-server-filter-mini-cards/synthesis.md @@ -0,0 +1,38 @@ +# Synthesis: Server filter mini-cards + +## Decision +Use a fixed-order horizontally scrolling rail of independently toggleable, flat two-line server mini-cards. + +```text +SERVERS · 3/5 ON › +┌──────────────────────┐ ┌──────────────────────┐ ┌──── +│ ● archive ON │ │ ○ studio OFF│ │ ● +│ archive.lan:4096 12 │ │ studio.lan:4096 7│ │ +└──────────────────────┘ └──────────────────────┘ └──── +``` + +The connection glyph communicates health only. `ON/OFF` communicates inclusion in blank-search Home browsing. Session count occupies a stable bottom-right column. The whole card is one semantic toggle target. + +## Key considerations that shaped this + +The selected direction preserves the user's simple two-line terminal reference while separating filter selection from connection health. Horizontal overflow is communicated by a partial next-card peek, a trailing or leading chevron based on scroll position, and `N/M ON` summary text. Cards retain stable ordering and rail position. + +Blank search combines and newest-first interleaves sessions/workspaces from all enabled servers. All-on is implicit, so there is no synthetic All card. All-off yields zero results with a clear Select all recovery action. Nonblank global search preserves but temporarily ignores enabled selections and clearly discloses that override. + +## What we ruled out and why + +- Three-line checkbox cards: clearer but unnecessarily tall for the primary Home rail. +- Filled Material chips/cards: too webby and visually heavy. +- Selection represented only by dot, color, border, or rail: ambiguous with connection health or inaccessible without color. +- Persistent expanded ledger: consumes too much Home workspace. +- A synthetic All card: can drift out of sync with independent toggles. + +## What we parked for later + +A three-line checkbox card remains a fallback if phone testing shows users confuse the leading connection glyph with selection despite explicit ON/OFF text. + +## Open questions + +- Exact card width after testing long display names, IPv6 endpoints, and maximum supported font scaling. +- Whether edge chevrons are informational only or 48dp scroll-by-one actions. +- Whether counts use a bare numeral visually or a compact localized noun; accessibility semantics always announce “N sessions.” diff --git a/docs/brainstorms/2026-07-15-server-filter-mini-cards/tree.md b/docs/brainstorms/2026-07-15-server-filter-mini-cards/tree.md new file mode 100644 index 00000000..a85c073b --- /dev/null +++ b/docs/brainstorms/2026-07-15-server-filter-mini-cards/tree.md @@ -0,0 +1,36 @@ +# Decision Trail: Server filter mini-cards + +## Pass 1 — Mini-card directions +Generated by: pending council +Human reviewed: yes, 2026-07-15 + +### 1. Two-line terminal identity +**State**: RESOLVED +**Source**: human +**Summary**: Hollow status mark plus server name on line one, raw endpoint on line two, with a small session-count indicator. +**Human response**: "I think I like smth like just [reference] and maybe then a small indicator for session count or smth?" +**Resolution**: Option A selected: connection glyph and name on line one, explicit ON/OFF at the right edge, raw endpoint and fixed-width session count on line two. + +### 2. Independent multi-select filters +**State**: RESOLVED +**Source**: human +**Summary**: Server cards are independently on or off instead of a single selected scope. +**Human response**: "instead of selecting a single server, they're either on or off and can be toggled" +**Resolution**: Each whole card independently toggles inclusion; connection state never changes the user's inclusion choice. + +### 3. Horizontally scrolling rail +**State**: RESOLVED +**Source**: human +**Summary**: Mini-cards live in a horizontal scrolling row with an explicit overflow affordance. +**Human response**: "a scrollable horizontal row (with ui affordance)" +**Resolution**: Fixed-order horizontal rail with a partial next-card peek, position-aware edge chevrons, and an aggregate `N/M ON` label. + +## Pass 2 — Selection +Generated by: human +Human reviewed: yes, 2026-07-15 + +### 1.1 Reference-preserving ON/OFF card +**State**: RESOLVED +**Source**: human +**Summary**: Compact two-line Option A within the horizontal rail. +**Human response**: "Option A i think? yeah horizontal rail sounds good." From 679a71583440ec7fc9e2965975fc32bc32330c23 Mon Sep 17 00:00:00 2001 From: Jasmin Le Roux Date: Sat, 18 Jul 2026 19:06:04 +0200 Subject: [PATCH 22/22] Harden upstream workspace workflows --- .../ui/components/TouchTargetSemanticsTest.kt | 114 ++++++ .../chat/ModelAgentSelectorSemanticsTest.kt | 86 +++++ .../ToolGroupWidgetPermissionTest.kt | 57 +++ .../BreadcrumbNavigationSemanticsTest.kt | 34 ++ .../AgentsConfigScreenSemanticsTest.kt | 32 ++ .../settings/ModelControlsSemanticsTest.kt | 103 ++++++ .../p4oc/ui/tabs/NavigationSemanticsTest.kt | 41 +++ app/src/main/AndroidManifest.xml | 12 +- .../java/dev/blazelight/p4oc/MainActivity.kt | 20 +- .../p4oc/core/datastore/SettingsDataStore.kt | 115 ++++-- .../p4oc/core/haptic/HapticFeedback.kt | 2 +- .../dev/blazelight/p4oc/core/log/AppLog.kt | 8 +- .../p4oc/core/network/ConnectionManager.kt | 153 +++++--- .../p4oc/core/network/MdnsDiscoveryManager.kt | 71 ++-- .../p4oc/core/network/OpenCodeApi.kt | 261 +++++++++---- .../p4oc/core/network/OpenCodeEventSource.kt | 64 ++-- .../p4oc/core/network/PtyWebSocketClient.kt | 112 +++--- .../core/network/ServerConnectionRegistry.kt | 275 ++++++++++++-- .../blazelight/p4oc/core/network/ServerUrl.kt | 47 +++ .../notification/NotificationEventObserver.kt | 150 +++++--- .../core/notification/NotificationHelper.kt | 128 ++++--- .../core/notification/NotificationRoute.kt | 111 ++++++ .../p4oc/core/security/CredentialStore.kt | 63 +++- .../p4oc/data/files/FilePathValidator.kt | 2 +- .../data/files/ofish/OfishCapabilities.kt | 11 +- .../data/files/ofish/OfishCapabilityParser.kt | 10 + .../data/files/ofish/OfishCapabilityProbe.kt | 14 + .../ofish/OfishCapabilityProbeCommand.kt | 13 +- .../data/files/ofish/OfishCommandBuilder.kt | 44 +++ .../data/files/ofish/OfishMutationClient.kt | 144 ++++++-- .../data/files/ofish/OfishMutationParser.kt | 33 +- .../data/files/ofish/OfishSessionFactory.kt | 9 +- .../data/files/ofish/OfishWorkspaceClient.kt | 2 +- .../p4oc/data/remote/dto/AgentDtos.kt | 2 + .../p4oc/data/remote/dto/AuthDtos.kt | 13 +- .../p4oc/data/remote/dto/CommandDtos.kt | 4 +- .../p4oc/data/remote/dto/ConfigDtos.kt | 1 + .../p4oc/data/remote/dto/EventDtos.kt | 3 + .../p4oc/data/remote/dto/FileDtos.kt | 22 -- .../p4oc/data/remote/dto/PartDtos.kt | 5 - .../p4oc/data/remote/dto/ProjectDtos.kt | 14 +- .../p4oc/data/remote/dto/ProviderDtos.kt | 29 +- .../p4oc/data/remote/dto/PtyDtos.kt | 4 +- .../p4oc/data/remote/dto/QuestionDtos.kt | 9 + .../p4oc/data/remote/dto/SessionDtos.kt | 28 ++ .../p4oc/data/remote/mapper/Mappers.kt | 39 +- .../p4oc/data/session/HydrationEventBuffer.kt | 12 +- .../p4oc/data/session/SessionReducer.kt | 11 + .../p4oc/data/session/SessionRepository.kt | 9 +- .../data/session/SessionRepositoryImpl.kt | 332 +++++++++++++---- .../data/session/SessionRepositoryProvider.kt | 16 +- .../data/workspace/SessionWorkspaceClient.kt | 3 + .../p4oc/data/workspace/WorkspaceClient.kt | 190 ++++++++-- .../dev/blazelight/p4oc/di/KoinModules.kt | 57 ++- .../dev/blazelight/p4oc/domain/model/Event.kt | 11 + .../p4oc/terminal/PtyTerminalClient.kt | 19 +- .../p4oc/ui/components/TermuxExtraKeysBar.kt | 79 +++- .../p4oc/ui/components/TermuxTerminalView.kt | 52 ++- .../p4oc/ui/components/TuiComponents.kt | 21 +- .../p4oc/ui/components/chat/ChatInputBar.kt | 70 ++-- .../p4oc/ui/components/chat/ChatMessage.kt | 136 ++++--- .../components/chat/InlinePermissionPrompt.kt | 13 +- .../ui/components/chat/ModelAgentSelector.kt | 140 +++++-- .../ui/components/chat/SlashCommandsPopup.kt | 3 +- .../p4oc/ui/components/chat/ToolComponents.kt | 4 +- .../components/toolwidgets/ExpandedWidgets.kt | 2 +- .../components/toolwidgets/ToolGroupWidget.kt | 57 ++- .../blazelight/p4oc/ui/navigation/NavGraph.kt | 34 +- .../blazelight/p4oc/ui/navigation/Screen.kt | 13 +- .../p4oc/ui/screens/chat/ChatScreen.kt | 115 +++++- .../p4oc/ui/screens/chat/ChatViewModel.kt | 168 +++++++-- .../ui/screens/chat/DialogQueueManager.kt | 41 ++- .../p4oc/ui/screens/chat/FilePickerManager.kt | 6 +- .../p4oc/ui/screens/chat/MessageBlockUtils.kt | 4 + .../p4oc/ui/screens/chat/ModelAgentManager.kt | 51 ++- .../p4oc/ui/screens/diff/SessionDiffScreen.kt | 30 +- .../ui/screens/files/FileExplorerScreen.kt | 189 ++++++++-- .../p4oc/ui/screens/files/FileViewerScreen.kt | 70 +++- .../p4oc/ui/screens/files/FilesViewModel.kt | 110 ++++-- .../files/editor/SoraTextMateBootstrap.kt | 4 +- .../screens/files/upload/UploadCoordinator.kt | 27 +- .../files/upload/UploadOrchestrator.kt | 67 +++- .../files/upload/UploadProgressSheet.kt | 8 +- .../p4oc/ui/screens/home/HomeScreen.kt | 30 +- .../p4oc/ui/screens/home/HomeSummary.kt | 2 +- .../ui/screens/licenses/LicensesScreen.kt | 5 +- .../ui/screens/projects/ProjectsScreen.kt | 12 +- .../ui/screens/projects/ProjectsViewModel.kt | 76 ++-- .../p4oc/ui/screens/server/ServerScreen.kt | 38 +- .../p4oc/ui/screens/server/ServerViewModel.kt | 55 ++- .../ui/screens/sessions/SessionListScreen.kt | 29 +- .../screens/sessions/SessionListViewModel.kt | 146 ++++++-- .../ui/screens/settings/AgentsConfigScreen.kt | 79 ++-- .../ui/screens/settings/ChatSettingsScreen.kt | 4 +- .../settings/ConnectionSettingsScreen.kt | 11 +- .../screens/settings/ModelControlsScreen.kt | 246 +++++++++---- .../settings/NotificationSettingsScreen.kt | 11 +- .../screens/settings/ProviderConfigScreen.kt | 211 ++++++++--- .../settings/ProviderConfigViewModel.kt | 165 +++++++-- .../ui/screens/settings/SettingsScreen.kt | 30 +- .../ui/screens/settings/SettingsViewModel.kt | 71 +++- .../p4oc/ui/screens/settings/SkillsScreen.kt | 54 +-- .../screens/settings/VisualSettingsScreen.kt | 4 +- .../ui/screens/terminal/TerminalScreen.kt | 74 +++- .../terminal/TerminalTranscriptStore.kt | 135 +++++++ .../ui/screens/terminal/TerminalViewModel.kt | 199 +++++++--- .../blazelight/p4oc/ui/tabs/MainTabScreen.kt | 232 ++++++++++-- .../ui/tabs/NotificationRouteOwnership.kt | 9 + .../dev/blazelight/p4oc/ui/tabs/TabBar.kt | 30 +- .../dev/blazelight/p4oc/ui/tabs/TabManager.kt | 20 +- .../dev/blazelight/p4oc/ui/tabs/TabNavHost.kt | 54 ++- .../dev/blazelight/p4oc/ui/theme/Sizing.kt | 4 +- .../ui/workspace/WorkspaceRepositoryOwner.kt | 11 +- app/src/main/res/drawable/ic_notification.xml | 9 + app/src/main/res/values/strings.xml | 72 +++- app/src/main/res/xml/backup_rules.xml | 1 + .../main/res/xml/data_extraction_rules.xml | 2 + .../main/res/xml/network_security_config.xml | 5 + .../core/datastore/SavedServerRegistryTest.kt | 29 +- .../SettingsDataStoreCorruptionTest.kt | 17 + .../SettingsDataStoreSelectedAgentTest.kt | 58 +++ .../SettingsDataStoreUploadDirectoriesTest.kt | 71 ++++ .../network/ConnectionManagerLoggingTest.kt | 119 ++++++ .../network/MdnsDiscoveryManagerSeedTest.kt | 61 ++- .../network/OpenCodeApiPtyContractTest.kt | 66 ++++ .../core/network/OpenCodeEventSourceTest.kt | 87 ++++- .../core/network/PtyWebSocketClientTest.kt | 34 ++ ...erConnectionRegistryGenerationStateTest.kt | 134 +++++++ .../network/ServerConnectionRegistryTest.kt | 348 +++++++++++++++++- .../p4oc/core/network/ServerUrlTest.kt | 45 +++ .../NotificationEventObserverTest.kt | 98 +++++ .../notification/NotificationHelperTest.kt | 61 +++ .../NotificationRouteCodecTest.kt | 128 +++++++ .../security/CredentialBackupRulesTest.kt | 29 ++ .../security/CredentialStoreRecoveryTest.kt | 59 +++ .../p4oc/data/files/FilePathValidatorTest.kt | 1 + .../files/ofish/OfishCapabilityParserTest.kt | 13 +- .../files/ofish/OfishCommandBuilderTest.kt | 32 ++ .../files/ofish/OfishCommandProcessTest.kt | 274 +++++++++++++- .../files/ofish/OfishFileRepositoryTest.kt | 8 +- .../files/ofish/OfishMutationClientTest.kt | 342 ++++++++++++++++- .../files/ofish/OfishMutationParserTest.kt | 124 ++++++- .../data/remote/dto/PtyDtoContractTest.kt | 30 ++ .../remote/dto/UpstreamContractDtoTest.kt | 122 ++++++ .../data/remote/mapper/EventMapperTest.kt | 172 +++++++++ .../data/session/HydrationEventBufferTest.kt | 21 +- .../session/QuestionReconciliationTest.kt | 12 + .../session/SessionOwnershipHydrationTest.kt | 105 ++++++ .../p4oc/data/session/SessionReducerTest.kt | 46 ++- .../data/session/SessionRepositoryImplTest.kt | 132 ++++++- .../session/SessionRepositoryLeaseTest.kt | 89 +++++ .../SessionRepositoryMessageStateTest.kt | 111 +++++- .../session/SessionRepositoryProviderTest.kt | 12 +- .../data/workspace/WorkspaceClientTest.kt | 200 +++++++++- .../p4oc/fakes/FakeSessionRepository.kt | 4 +- .../p4oc/fakes/FakeWorkspaceClient.kt | 4 + .../chat/ChatViewModelDraftPersistenceTest.kt | 88 +++-- .../p4oc/ui/screens/chat/ChatViewModelTest.kt | 157 ++++---- .../ui/screens/chat/DialogQueueManagerTest.kt | 58 ++- .../ui/screens/chat/ModelAgentManagerTest.kt | 98 ++--- .../PendingPermissionAttentionVersionTest.kt | 154 ++++++++ .../screens/files/FilesViewModelEditTest.kt | 145 +++++++- .../files/upload/UploadOrchestratorTest.kt | 103 ++++++ .../ui/screens/home/HomeSummaryBuilderTest.kt | 2 +- .../screens/projects/ProjectsViewModelTest.kt | 166 +++++++++ .../server/ServerViewModelIssue31Test.kt | 75 +++- .../sessions/SessionListViewModelTest.kt | 93 ++++- .../settings/ModelControlsViewModelTest.kt | 163 +++++--- .../settings/ProviderConfigViewModelTest.kt | 94 +++-- .../settings/SettingsViewModelRegistryTest.kt | 129 +++++++ .../WorkspaceSettingsViewModelTest.kt | 109 ++++++ .../terminal/TerminalTranscriptStoreTest.kt | 121 ++++++ .../ui/tabs/NotificationRouteOwnershipTest.kt | 74 ++++ .../ui/tabs/PendingStartDispositionTest.kt | 69 ++++ .../p4oc/ui/tabs/TabManagerPersistenceTest.kt | 125 +++++++ 175 files changed, 10396 insertions(+), 1749 deletions(-) create mode 100644 app/src/androidTest/java/dev/blazelight/p4oc/ui/components/TouchTargetSemanticsTest.kt create mode 100644 app/src/androidTest/java/dev/blazelight/p4oc/ui/components/chat/ModelAgentSelectorSemanticsTest.kt create mode 100644 app/src/androidTest/java/dev/blazelight/p4oc/ui/components/toolwidgets/ToolGroupWidgetPermissionTest.kt create mode 100644 app/src/androidTest/java/dev/blazelight/p4oc/ui/screens/files/BreadcrumbNavigationSemanticsTest.kt create mode 100644 app/src/androidTest/java/dev/blazelight/p4oc/ui/screens/settings/AgentsConfigScreenSemanticsTest.kt create mode 100644 app/src/androidTest/java/dev/blazelight/p4oc/ui/screens/settings/ModelControlsSemanticsTest.kt create mode 100644 app/src/androidTest/java/dev/blazelight/p4oc/ui/tabs/NavigationSemanticsTest.kt create mode 100644 app/src/main/java/dev/blazelight/p4oc/core/notification/NotificationRoute.kt create mode 100644 app/src/main/java/dev/blazelight/p4oc/ui/screens/terminal/TerminalTranscriptStore.kt create mode 100644 app/src/main/java/dev/blazelight/p4oc/ui/tabs/NotificationRouteOwnership.kt create mode 100644 app/src/main/res/drawable/ic_notification.xml create mode 100644 app/src/test/java/dev/blazelight/p4oc/core/datastore/SettingsDataStoreCorruptionTest.kt create mode 100644 app/src/test/java/dev/blazelight/p4oc/core/datastore/SettingsDataStoreSelectedAgentTest.kt create mode 100644 app/src/test/java/dev/blazelight/p4oc/core/datastore/SettingsDataStoreUploadDirectoriesTest.kt create mode 100644 app/src/test/java/dev/blazelight/p4oc/core/network/ConnectionManagerLoggingTest.kt create mode 100644 app/src/test/java/dev/blazelight/p4oc/core/network/OpenCodeApiPtyContractTest.kt create mode 100644 app/src/test/java/dev/blazelight/p4oc/core/network/PtyWebSocketClientTest.kt create mode 100644 app/src/test/java/dev/blazelight/p4oc/core/network/ServerConnectionRegistryGenerationStateTest.kt create mode 100644 app/src/test/java/dev/blazelight/p4oc/core/notification/NotificationEventObserverTest.kt create mode 100644 app/src/test/java/dev/blazelight/p4oc/core/notification/NotificationHelperTest.kt create mode 100644 app/src/test/java/dev/blazelight/p4oc/core/notification/NotificationRouteCodecTest.kt create mode 100644 app/src/test/java/dev/blazelight/p4oc/core/security/CredentialBackupRulesTest.kt create mode 100644 app/src/test/java/dev/blazelight/p4oc/core/security/CredentialStoreRecoveryTest.kt create mode 100644 app/src/test/java/dev/blazelight/p4oc/data/remote/dto/PtyDtoContractTest.kt create mode 100644 app/src/test/java/dev/blazelight/p4oc/data/remote/dto/UpstreamContractDtoTest.kt create mode 100644 app/src/test/java/dev/blazelight/p4oc/data/session/SessionOwnershipHydrationTest.kt create mode 100644 app/src/test/java/dev/blazelight/p4oc/data/session/SessionRepositoryLeaseTest.kt create mode 100644 app/src/test/java/dev/blazelight/p4oc/ui/screens/projects/ProjectsViewModelTest.kt create mode 100644 app/src/test/java/dev/blazelight/p4oc/ui/screens/settings/SettingsViewModelRegistryTest.kt create mode 100644 app/src/test/java/dev/blazelight/p4oc/ui/screens/settings/WorkspaceSettingsViewModelTest.kt create mode 100644 app/src/test/java/dev/blazelight/p4oc/ui/screens/terminal/TerminalTranscriptStoreTest.kt create mode 100644 app/src/test/java/dev/blazelight/p4oc/ui/tabs/NotificationRouteOwnershipTest.kt create mode 100644 app/src/test/java/dev/blazelight/p4oc/ui/tabs/PendingStartDispositionTest.kt diff --git a/app/src/androidTest/java/dev/blazelight/p4oc/ui/components/TouchTargetSemanticsTest.kt b/app/src/androidTest/java/dev/blazelight/p4oc/ui/components/TouchTargetSemanticsTest.kt new file mode 100644 index 00000000..94784d39 --- /dev/null +++ b/app/src/androidTest/java/dev/blazelight/p4oc/ui/components/TouchTargetSemanticsTest.kt @@ -0,0 +1,114 @@ +package dev.blazelight.p4oc.ui.components + +import androidx.compose.material3.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertHeightIsAtLeast +import androidx.compose.ui.test.assertWidthIsAtLeast +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dev.blazelight.p4oc.domain.model.Permission +import dev.blazelight.p4oc.ui.components.chat.InlinePermissionPrompt +import dev.blazelight.p4oc.ui.theme.PocketCodeTheme +import dev.blazelight.p4oc.ui.theme.Sizing +import kotlinx.serialization.json.buildJsonObject +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class TouchTargetSemanticsTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun sharedButtonsExposeMinimumTouchTargets() { + composeRule.setContent { + PocketCodeTheme { + TuiButton(onClick = {}, modifier = Modifier.testTag("button")) { Text("Button") } + TuiOutlinedButton(onClick = {}, modifier = Modifier.testTag("outlined")) { Text("Outlined") } + TuiTextButton(onClick = {}, modifier = Modifier.testTag("text")) { Text("Text") } + TuiIconButton(onClick = {}, modifier = Modifier.testTag("icon")) { Text("+") } + } + } + + listOf("button", "outlined", "text", "icon").forEach { tag -> + composeRule.onNodeWithTag(tag) + .assertHeightIsAtLeast(Sizing.minTouchTarget) + .assertWidthIsAtLeast(Sizing.minTouchTarget) + } + } + + @Test + fun inlinePermissionActionsExposeMinimumTouchTargets() { + composeRule.setContent { + PocketCodeTheme { + InlinePermissionPrompt( + permission = Permission( + id = "permission-1", + type = "bash", + patterns = listOf("echo"), + sessionID = "session-1", + messageID = "message-1", + metadata = buildJsonObject {}, + always = emptyList(), + ), + onAllow = {}, + onAlways = {}, + onReject = {}, + ) + } + } + + listOf( + "permission_deny_permission-1", + "permission_always_allow_permission-1", + "permission_allow_once_permission-1", + ).forEach { tag -> + composeRule.onNodeWithTag(tag) + .assertHeightIsAtLeast(Sizing.minTouchTarget) + .assertWidthIsAtLeast(Sizing.minTouchTarget) + } + } + + @Test + fun inlinePermissionActionsRouteTheExactRequest() { + val routed = mutableListOf() + composeRule.setContent { + PocketCodeTheme { + InlinePermissionPrompt( + permission = Permission( + id = "permission-exact", + type = "bash", + patterns = listOf("echo"), + sessionID = "session-1", + messageID = "", + metadata = buildJsonObject {}, + always = emptyList(), + ), + onAllow = { routed += "permission-exact:once" }, + onAlways = { routed += "permission-exact:always" }, + onReject = { routed += "permission-exact:reject" }, + ) + } + } + + composeRule.onNodeWithTag("permission_allow_once_permission-exact").performClick() + composeRule.onNodeWithTag("permission_always_allow_permission-exact").performClick() + composeRule.onNodeWithTag("permission_deny_permission-exact").performClick() + + composeRule.runOnIdle { + assertEquals( + listOf( + "permission-exact:once", + "permission-exact:always", + "permission-exact:reject", + ), + routed, + ) + } + } +} diff --git a/app/src/androidTest/java/dev/blazelight/p4oc/ui/components/chat/ModelAgentSelectorSemanticsTest.kt b/app/src/androidTest/java/dev/blazelight/p4oc/ui/components/chat/ModelAgentSelectorSemanticsTest.kt new file mode 100644 index 00000000..a8d0957a --- /dev/null +++ b/app/src/androidTest/java/dev/blazelight/p4oc/ui/components/chat/ModelAgentSelectorSemanticsTest.kt @@ -0,0 +1,86 @@ +package dev.blazelight.p4oc.ui.components.chat + +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.hasAnyDescendant +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import dev.blazelight.p4oc.R +import dev.blazelight.p4oc.data.remote.dto.ModelDto +import dev.blazelight.p4oc.data.remote.dto.ModelInput +import dev.blazelight.p4oc.ui.theme.PocketCodeTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ModelAgentSelectorSemanticsTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun selectedProviderFilterExposesTabAndSelectionSemantics() { + composeRule.setContent { + PocketCodeTheme { + ModelPickerDialog( + availableModels = listOf("provider" to model()), + selectedModel = null, + favoriteModels = emptySet(), + recentModels = emptyList(), + onModelSelected = {}, + onToggleFavorite = {}, + onDismiss = {}, + ) + } + } + + composeRule.onNode( + hasAnyDescendant(hasText("All")) and hasRole(Role.Tab), + useUnmergedTree = true, + ).assertIsSelected() + } + + @Test + fun selectedModelAndFavoriteActionExposeMeaningfulSemantics() { + val modelInput = ModelInput(providerID = "provider", modelID = "model") + val context = InstrumentationRegistry.getInstrumentation().targetContext + composeRule.setContent { + PocketCodeTheme { + ModelPickerDialog( + availableModels = listOf("provider" to model()), + selectedModel = modelInput, + favoriteModels = setOf(modelInput), + recentModels = emptyList(), + onModelSelected = {}, + onToggleFavorite = {}, + onDismiss = {}, + ) + } + } + + composeRule.onNode( + hasAnyDescendant(hasText("Model One")) and hasRole(Role.RadioButton), + useUnmergedTree = true, + ).assertIsSelected() + composeRule.onNodeWithContentDescription( + context.getString(R.string.cd_remove_from_favorites), + useUnmergedTree = true, + ).assert(hasContentDescription(context.getString(R.string.cd_remove_from_favorites))) + } + + private fun hasRole(role: Role): SemanticsMatcher = + SemanticsMatcher.expectValue(SemanticsProperties.Role, role) + + private fun model() = ModelDto( + id = "model", + providerId = "provider", + name = "Model One", + ) +} diff --git a/app/src/androidTest/java/dev/blazelight/p4oc/ui/components/toolwidgets/ToolGroupWidgetPermissionTest.kt b/app/src/androidTest/java/dev/blazelight/p4oc/ui/components/toolwidgets/ToolGroupWidgetPermissionTest.kt new file mode 100644 index 00000000..3f925cc5 --- /dev/null +++ b/app/src/androidTest/java/dev/blazelight/p4oc/ui/components/toolwidgets/ToolGroupWidgetPermissionTest.kt @@ -0,0 +1,57 @@ +package dev.blazelight.p4oc.ui.components.toolwidgets + +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dev.blazelight.p4oc.domain.model.Part +import dev.blazelight.p4oc.domain.model.ToolState +import dev.blazelight.p4oc.ui.theme.PocketCodeTheme +import kotlinx.serialization.json.buildJsonObject +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ToolGroupWidgetPermissionTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun groupedPendingToolExposesEveryServerPermissionResponse() { + val responses = mutableListOf() + composeRule.setContent { + PocketCodeTheme { + ToolGroupWidget( + tools = listOf(pendingTool()), + defaultState = ToolWidgetState.COMPACT, + pendingPermissionIdsByCallId = mapOf("call-1" to "permission-1"), + onToolApprove = { responses += "once:$it" }, + onToolAlways = { responses += "always:$it" }, + onToolDeny = { responses += "deny:$it" }, + ) + } + } + + composeRule.onNodeWithTag("tool_permission_allow_once_permission-1").performClick() + composeRule.onNodeWithTag("tool_permission_allow_always_permission-1").performClick() + composeRule.onNodeWithTag("tool_permission_deny_permission-1").performClick() + + composeRule.runOnIdle { + assertEquals( + listOf("once:permission-1", "always:permission-1", "deny:permission-1"), + responses, + ) + } + } + + private fun pendingTool() = Part.Tool( + id = "part-1", + sessionID = "session-1", + messageID = "message-1", + callID = "call-1", + toolName = "bash", + state = ToolState.Pending(input = buildJsonObject {}, rawInput = ""), + ) +} diff --git a/app/src/androidTest/java/dev/blazelight/p4oc/ui/screens/files/BreadcrumbNavigationSemanticsTest.kt b/app/src/androidTest/java/dev/blazelight/p4oc/ui/screens/files/BreadcrumbNavigationSemanticsTest.kt new file mode 100644 index 00000000..ad98bbd2 --- /dev/null +++ b/app/src/androidTest/java/dev/blazelight/p4oc/ui/screens/files/BreadcrumbNavigationSemanticsTest.kt @@ -0,0 +1,34 @@ +package dev.blazelight.p4oc.ui.screens.files + +import androidx.compose.ui.test.assertHeightIsAtLeast +import androidx.compose.ui.test.assertWidthIsAtLeast +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dev.blazelight.p4oc.ui.theme.PocketCodeTheme +import dev.blazelight.p4oc.ui.theme.Sizing +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class BreadcrumbNavigationSemanticsTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun breadcrumbSegmentsExposeMinimumSemanticTargets() { + composeRule.setContent { + PocketCodeTheme { + BreadcrumbNavigation(path = "src/main", onNavigateTo = {}) + } + } + + listOf("files_breadcrumb_root", "files_breadcrumb_segment_0", "files_breadcrumb_segment_1") + .forEach { tag -> + composeRule.onNodeWithTag(tag) + .assertHeightIsAtLeast(Sizing.minTouchTarget) + .assertWidthIsAtLeast(Sizing.minTouchTarget) + } + } +} diff --git a/app/src/androidTest/java/dev/blazelight/p4oc/ui/screens/settings/AgentsConfigScreenSemanticsTest.kt b/app/src/androidTest/java/dev/blazelight/p4oc/ui/screens/settings/AgentsConfigScreenSemanticsTest.kt new file mode 100644 index 00000000..cfc39872 --- /dev/null +++ b/app/src/androidTest/java/dev/blazelight/p4oc/ui/screens/settings/AgentsConfigScreenSemanticsTest.kt @@ -0,0 +1,32 @@ +package dev.blazelight.p4oc.ui.screens.settings + +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dev.blazelight.p4oc.ui.theme.PocketCodeTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class AgentsConfigScreenSemanticsTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun toolLabelIsPresentedAsNonActionableText() { + composeRule.setContent { + PocketCodeTheme { + AgentToolLabel(tool = "bash") + } + } + + composeRule.onNodeWithText("bash") + .assert(!hasClickAction()) + .assert(SemanticsMatcher.keyNotDefined(SemanticsProperties.Role)) + } +} diff --git a/app/src/androidTest/java/dev/blazelight/p4oc/ui/screens/settings/ModelControlsSemanticsTest.kt b/app/src/androidTest/java/dev/blazelight/p4oc/ui/screens/settings/ModelControlsSemanticsTest.kt new file mode 100644 index 00000000..4364eb7d --- /dev/null +++ b/app/src/androidTest/java/dev/blazelight/p4oc/ui/screens/settings/ModelControlsSemanticsTest.kt @@ -0,0 +1,103 @@ +package dev.blazelight.p4oc.ui.screens.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.state.ToggleableState +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertHasNoClickAction +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.hasAnyDescendant +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import dev.blazelight.p4oc.R +import dev.blazelight.p4oc.ui.theme.PocketCodeTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ModelControlsSemanticsTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun selectedModelAndFavoriteExposeTruthfulSemantics() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + composeRule.setContent { + PocketCodeTheme { + Column(Modifier.selectableGroup()) { + ModelCard( + model = model(isFavorite = true), + isSelected = true, + onSelect = {}, + onToggleFavorite = {}, + ) + } + } + } + + composeRule.onNode( + hasAnyDescendant(hasText("Model One")) and hasRole(Role.RadioButton), + useUnmergedTree = true, + ) + .assertIsSelected() + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.StateDescription, + context.getString(R.string.models_current_model), + ) + ) + + composeRule.onNodeWithContentDescription( + context.getString(R.string.cd_remove_from_favorites), + useUnmergedTree = true, + ) + .assert(hasContentDescription(context.getString(R.string.cd_remove_from_favorites))) + .assert(SemanticsMatcher.expectValue(SemanticsProperties.ToggleableState, ToggleableState.On)) + } + + @Test + fun capabilityBadgesHaveNoFakeClickActions() { + composeRule.setContent { + PocketCodeTheme { + Column(Modifier.selectableGroup()) { + ModelCard( + model = model(isFavorite = false), + isSelected = false, + onSelect = {}, + onToggleFavorite = {}, + ) + } + } + } + + composeRule.onNodeWithContentDescription( + InstrumentationRegistry.getInstrumentation().targetContext + .getString(R.string.cd_add_to_favorites), + useUnmergedTree = true, + ).assert(SemanticsMatcher.expectValue(SemanticsProperties.ToggleableState, ToggleableState.Off)) + + composeRule.onNode(hasText("Tools"), useUnmergedTree = true).assertHasNoClickAction() + composeRule.onNode(hasText("Reasoning"), useUnmergedTree = true).assertHasNoClickAction() + } + + private fun hasRole(role: Role): SemanticsMatcher = + SemanticsMatcher.expectValue(SemanticsProperties.Role, role) + + private fun model(isFavorite: Boolean) = ModelInfo( + id = "model", + name = "Model One", + providerId = "provider", + supportsTools = true, + supportsReasoning = true, + isFavorite = isFavorite, + ) +} diff --git a/app/src/androidTest/java/dev/blazelight/p4oc/ui/tabs/NavigationSemanticsTest.kt b/app/src/androidTest/java/dev/blazelight/p4oc/ui/tabs/NavigationSemanticsTest.kt new file mode 100644 index 00000000..8487dc43 --- /dev/null +++ b/app/src/androidTest/java/dev/blazelight/p4oc/ui/tabs/NavigationSemanticsTest.kt @@ -0,0 +1,41 @@ +package dev.blazelight.p4oc.ui.tabs + +import androidx.compose.ui.test.assertIsNotSelected +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dev.blazelight.p4oc.ui.theme.PocketCodeTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class NavigationSemanticsTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun activeTabExposesSelectedSemantics() { + val home = TabInstance.home() + val work = TabInstance(TabState(id = "work")) + + composeRule.setContent { + PocketCodeTheme { + TabBar( + tabs = listOf(home, work), + activeTabId = work.id, + tabTitles = mapOf(home.id to "Home", work.id to "Files"), + tabIcons = mapOf(home.id to getIconForTab(home), work.id to getIconForTab(work)), + tabConnectionStates = emptyMap(), + onTabClick = {}, + onTabClose = {}, + onAddClick = {}, + ) + } + } + + composeRule.onNodeWithTag("tab_home").assertIsNotSelected() + composeRule.onNodeWithTag("work_tab_work").assertIsSelected() + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 302fa0cb..dc13b9ab 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -11,17 +11,6 @@ - - - - - - - - - - - diff --git a/app/src/main/java/dev/blazelight/p4oc/MainActivity.kt b/app/src/main/java/dev/blazelight/p4oc/MainActivity.kt index b146b0d9..960c99c8 100644 --- a/app/src/main/java/dev/blazelight/p4oc/MainActivity.kt +++ b/app/src/main/java/dev/blazelight/p4oc/MainActivity.kt @@ -1,5 +1,6 @@ package dev.blazelight.p4oc +import android.content.Intent import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -12,19 +13,24 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.compose.rememberNavController import dev.blazelight.p4oc.core.datastore.SettingsDataStore +import dev.blazelight.p4oc.core.notification.NotificationRoute +import dev.blazelight.p4oc.core.notification.NotificationRouteCodec import dev.blazelight.p4oc.ui.navigation.NavGraph import dev.blazelight.p4oc.ui.navigation.Screen import dev.blazelight.p4oc.ui.theme.LocalOpenCodeTheme import dev.blazelight.p4oc.ui.theme.PocketCodeTheme +import kotlinx.coroutines.flow.MutableStateFlow import org.koin.android.ext.android.inject class MainActivity : ComponentActivity() { private val settingsDataStore: SettingsDataStore by inject() + private val pendingNotificationRoute = MutableStateFlow(null) override fun onCreate(savedInstanceState: Bundle?) { installSplashScreen() super.onCreate(savedInstanceState) + pendingNotificationRoute.value = NotificationRouteCodec.read(intent) setContent { val themeMode by settingsDataStore.themeMode.collectAsStateWithLifecycle(initialValue = "system") @@ -48,10 +54,22 @@ class MainActivity : ComponentActivity() { val navController = rememberNavController() NavGraph( navController = navController, - startDestination = Screen.Server.route + startDestination = Screen.Server.route, + pendingNotificationRoute = pendingNotificationRoute, + onNotificationRouteConsumed = { route -> + if (pendingNotificationRoute.compareAndSet(route, null)) { + NotificationRouteCodec.clear(intent) + } + }, ) } } } } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + NotificationRouteCodec.read(intent)?.let { pendingNotificationRoute.value = it } + } } diff --git a/app/src/main/java/dev/blazelight/p4oc/core/datastore/SettingsDataStore.kt b/app/src/main/java/dev/blazelight/p4oc/core/datastore/SettingsDataStore.kt index 2230ff0d..6e91b941 100644 --- a/app/src/main/java/dev/blazelight/p4oc/core/datastore/SettingsDataStore.kt +++ b/app/src/main/java/dev/blazelight/p4oc/core/datastore/SettingsDataStore.kt @@ -3,6 +3,7 @@ package dev.blazelight.p4oc.core.datastore import android.content.Context import androidx.datastore.core.DataMigration import androidx.datastore.core.DataStore +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler import androidx.datastore.preferences.core.* import androidx.datastore.preferences.preferencesDataStore import dev.blazelight.p4oc.core.log.AppLog @@ -23,12 +24,70 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +internal val settingsCorruptionHandler: ReplaceFileCorruptionHandler = + ReplaceFileCorruptionHandler { emptyPreferences() } + private val Context.dataStore: DataStore by preferencesDataStore( name = "settings", + corruptionHandler = settingsCorruptionHandler, produceMigrations = { listOf(removeDeadWorkspacePrefsMigration()) }, ) private const val TAG = "SettingsDataStore" +internal const val MAX_SESSION_AGENT_SELECTIONS = 100 + +private fun parseSessionAgentSelections(stored: String?): LinkedHashMap = + stored?.let { + runCatching { + Json.decodeFromString>(it) + }.getOrNull() + } ?: linkedMapOf() + +internal fun selectedAgentForSession(stored: String?, sessionId: String): String? = + parseSessionAgentSelections(stored)[sessionId] + +internal fun updatedSessionAgentSelections( + stored: String?, + sessionId: String, + agentName: String, +): String { + val selections = parseSessionAgentSelections(stored) + selections.remove(sessionId) + selections[sessionId] = agentName + while (selections.size > MAX_SESSION_AGENT_SELECTIONS) { + selections.remove(selections.keys.first()) + } + return Json.encodeToString(selections) +} + +internal const val MAX_LAST_UPLOAD_DIRECTORIES = 50 + +internal fun decodeLastUploadDirectories(encoded: String?): Map { + if (encoded.isNullOrBlank()) return emptyMap() + return runCatching { + Json.decodeFromString>(encoded).apply { + while (size > MAX_LAST_UPLOAD_DIRECTORIES) { + remove(keys.first()) + } + } + }.getOrDefault(emptyMap()) +} + +internal fun updateLastUploadDirectories( + current: Map, + workspaceKey: String, + path: String?, +): Map { + if (workspaceKey.isBlank()) return current + + val updated = LinkedHashMap(current) + updated.remove(workspaceKey) + if (!path.isNullOrBlank()) updated[workspaceKey] = path + while (updated.size > MAX_LAST_UPLOAD_DIRECTORIES) { + updated.remove(updated.keys.first()) + } + return updated +} class SettingsDataStore constructor( private val context: Context, @@ -111,7 +170,7 @@ class SettingsDataStore constructor( cachedServerUrl = prefs[KEY_SERVER_URL] ?: DEFAULT_LOCAL_URL cachedUsername = prefs[KEY_USERNAME] } catch (e: Exception) { - AppLog.e(TAG, "Error during init", e) + AppLog.e(TAG, "Error during init (${e::class.simpleName})") } } } @@ -319,7 +378,7 @@ class SettingsDataStore constructor( try { parseRecentServersLenient(stored) } catch (e: Exception) { - AppLog.e(TAG, "Error parsing recent servers", e) + AppLog.e(TAG, "Error parsing recent servers (${e::class.simpleName})") emptyList() } } @@ -413,20 +472,20 @@ class SettingsDataStore constructor( context.dataStore.edit { prefs -> val stored = prefs[KEY_RECENT_SERVERS] ?: "" - val existingServers = if (stored.isBlank()) { + val servers = if (stored == null) { mutableListOf() } else { try { parseRecentServersLenient(stored).toMutableList() } catch (e: Exception) { - AppLog.e(TAG, "Error parsing recent servers in addRecentServer", e) + AppLog.e(TAG, "Error parsing recent servers in addRecentServer (${e::class.simpleName})") mutableListOf() } } - existingServers.removeAll { it.url == url } - existingServers.add(0, RecentServer(url, name, username, allowInsecure)) - val trimmed = existingServers.take(MAX_RECENT_SERVERS) + servers.removeAll { it.url == url } + servers.add(0, RecentServer(url, name, username, allowInsecure)) + val trimmed = servers.take(MAX_RECENT_SERVERS) prefs[KEY_RECENT_SERVERS] = json.encodeToString(trimmed) } @@ -441,7 +500,7 @@ class SettingsDataStore constructor( val servers = try { parseRecentServersLenient(stored).filter { it.url != url } } catch (e: Exception) { - AppLog.e(TAG, "Error parsing recent servers in removeRecentServer", e) + AppLog.e(TAG, "Error parsing recent servers in removeRecentServer (${e::class.simpleName})") return@edit } prefs[KEY_RECENT_SERVERS] = json.encodeToString(servers) @@ -521,26 +580,14 @@ class SettingsDataStore constructor( } val lastUploadDirectoriesByWorkspace: Flow> = context.dataStore.data.map { prefs -> - prefs[KEY_CHAT_LAST_UPLOAD_DIR_BY_WORKSPACE] - ?.takeIf { it.isNotBlank() } - ?.let { encoded -> - runCatching { json.decodeFromString>(encoded) }.getOrDefault(emptyMap()) - } - ?: emptyMap() + decodeLastUploadDirectories(prefs[KEY_CHAT_LAST_UPLOAD_DIR_BY_WORKSPACE]) } suspend fun setLastUploadDirectory(workspaceKey: String, path: String?) { if (workspaceKey.isBlank()) return context.dataStore.edit { prefs -> - val current = prefs[KEY_CHAT_LAST_UPLOAD_DIR_BY_WORKSPACE] - ?.takeIf { it.isNotBlank() } - ?.let { encoded -> runCatching { json.decodeFromString>(encoded) }.getOrNull() } - .orEmpty() - val updated = if (path.isNullOrBlank()) { - current - workspaceKey - } else { - current + (workspaceKey to path) - } + val current = decodeLastUploadDirectories(prefs[KEY_CHAT_LAST_UPLOAD_DIR_BY_WORKSPACE]) + val updated = updateLastUploadDirectories(current, workspaceKey, path) if (updated.isEmpty()) { prefs.remove(KEY_CHAT_LAST_UPLOAD_DIR_BY_WORKSPACE) } else { @@ -621,17 +668,16 @@ class SettingsDataStore constructor( suspend fun getSelectedAgentForSession(sessionId: String): String? { val stored = context.dataStore.data.first()[KEY_SESSION_AGENTS] ?: return null - return runCatching { - json.decodeFromString>(stored)[sessionId] - }.getOrNull() + return selectedAgentForSession(stored, sessionId) } suspend fun setSelectedAgentForSession(sessionId: String, agentName: String) { context.dataStore.edit { prefs -> - val current = prefs[KEY_SESSION_AGENTS]?.let { stored -> - runCatching { json.decodeFromString>(stored) }.getOrDefault(emptyMap()) - }.orEmpty() - prefs[KEY_SESSION_AGENTS] = json.encodeToString(current + (sessionId to agentName)) + prefs[KEY_SESSION_AGENTS] = updatedSessionAgentSelections( + stored = prefs[KEY_SESSION_AGENTS], + sessionId = sessionId, + agentName = agentName, + ) } } @@ -681,13 +727,16 @@ class SettingsDataStore constructor( }.getOrNull() } - return SavedServerRegistry.merge(saved + listOfNotNull(lastConnection) + recent) + // The active connection is the newest explicit configuration. Keep it first so + // revocable settings (notably allowInsecure) cannot be resurrected by stale + // saved/recent representations of the same endpoint. + return SavedServerRegistry.merge(listOfNotNull(lastConnection) + recent + saved) } private fun parsePersistedTabState(stored: String): PersistedTabState? = try { migrateLegacyPersistedTabState(stored) ?: json.decodeFromString(stored) } catch (e: Exception) { - AppLog.w(TAG, "Ignoring invalid persisted tab state: ${e.message}") + AppLog.w(TAG, "Ignoring invalid persisted tab state (${e::class.simpleName})") null } @@ -837,7 +886,7 @@ internal object SavedServerRegistry { private fun mergeServer(primary: SavedServer, fallback: SavedServer): SavedServer = primary.copy( displayName = primary.displayName.takeIf { it.isNotBlank() } ?: fallback.displayName, username = primary.username ?: fallback.username, - allowInsecure = primary.allowInsecure || fallback.allowInsecure, + allowInsecure = primary.allowInsecure, pinned = primary.pinned || fallback.pinned, defaultWorkspace = primary.defaultWorkspace ?: fallback.defaultWorkspace, lastConnectedAt = listOfNotNull(primary.lastConnectedAt, fallback.lastConnectedAt).maxOrNull(), diff --git a/app/src/main/java/dev/blazelight/p4oc/core/haptic/HapticFeedback.kt b/app/src/main/java/dev/blazelight/p4oc/core/haptic/HapticFeedback.kt index 2ea11355..ec57f033 100644 --- a/app/src/main/java/dev/blazelight/p4oc/core/haptic/HapticFeedback.kt +++ b/app/src/main/java/dev/blazelight/p4oc/core/haptic/HapticFeedback.kt @@ -49,7 +49,7 @@ class HapticFeedback(private val context: Context) { } } } catch (e: Exception) { - AppLog.w(TAG, "vibrate failed: ${e.message}", e) + AppLog.w(TAG, "Vibration failed (${e::class.simpleName})") } } diff --git a/app/src/main/java/dev/blazelight/p4oc/core/log/AppLog.kt b/app/src/main/java/dev/blazelight/p4oc/core/log/AppLog.kt index 894e4667..b96816d2 100644 --- a/app/src/main/java/dev/blazelight/p4oc/core/log/AppLog.kt +++ b/app/src/main/java/dev/blazelight/p4oc/core/log/AppLog.kt @@ -30,18 +30,18 @@ object AppLog { } fun w(tag: String, msg: String) { - Log.w(tag, msg) + if (BuildConfig.DEBUG) Log.w(tag, msg) } fun w(tag: String, msg: String, throwable: Throwable?) { - Log.w(tag, msg, throwable) + if (BuildConfig.DEBUG) Log.w(tag, msg, throwable) } fun e(tag: String, msg: String) { - Log.e(tag, msg) + if (BuildConfig.DEBUG) Log.e(tag, msg) } fun e(tag: String, msg: String, throwable: Throwable?) { - Log.e(tag, msg, throwable) + if (BuildConfig.DEBUG) Log.e(tag, msg, throwable) } } diff --git a/app/src/main/java/dev/blazelight/p4oc/core/network/ConnectionManager.kt b/app/src/main/java/dev/blazelight/p4oc/core/network/ConnectionManager.kt index b5d25df0..83c0867c 100644 --- a/app/src/main/java/dev/blazelight/p4oc/core/network/ConnectionManager.kt +++ b/app/src/main/java/dev/blazelight/p4oc/core/network/ConnectionManager.kt @@ -5,29 +5,26 @@ import dev.blazelight.p4oc.core.datastore.SettingsDataStore import dev.blazelight.p4oc.core.log.AppLog import dev.blazelight.p4oc.data.remote.dto.ProjectDto import dev.blazelight.p4oc.data.remote.mapper.EventMapper -import dev.blazelight.p4oc.domain.server.ScopedEvent import dev.blazelight.p4oc.domain.server.ServerGeneration -import dev.blazelight.p4oc.domain.server.ServerRef -import dev.blazelight.p4oc.domain.server.WorkspaceKey +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import kotlinx.serialization.json.Json import okhttp3.ConnectionPool import okhttp3.Credentials import okhttp3.Dispatcher +import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.Interceptor import okhttp3.MediaType.Companion.toMediaType @@ -48,6 +45,7 @@ class ConnectionManager constructor( } private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private val connectionLifecycleLock = Any() private var sseForwardingJob: Job? = null private var sseEscalationJob: Job? = null private var generationCounter: Long = 0L @@ -96,28 +94,19 @@ class ConnectionManager constructor( fun getEventSource(): OpenCodeEventSource? = _connection.value?.eventSource - @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) - val scopedEvents: Flow - get() = connection.flatMapLatest { conn -> - if (conn == null) { - emptyFlow() - } else { - val serverRef = ServerRef.fromEndpoint(conn.config.url) - conn.eventSource.directoryEvents.map { directoryEvent -> - ScopedEvent( - serverRef = serverRef, - generation = conn.generation, - workspaceKey = directoryEvent.directory?.takeIf { - it.isNotBlank() - }?.let(WorkspaceKey::Directory) ?: WorkspaceKey.Global, - event = directoryEvent.event, - ) - } - } - } - + @Suppress("ReturnCount") suspend fun connect(config: ServerConfig, password: String? = null): Result> { - AppLog.d(TAG, "Connecting to ${config.url}") + AppLog.d(TAG, "Connecting") + + if (config.username != null && password != null && + !ServerUrl.allowsCleartextCredentials(config.url) + ) { + return Result.failure( + IllegalArgumentException( + "Credentials cannot be sent over HTTP outside a private local network", + ), + ) + } disconnect() _connectionState.value = ConnectionState.Connecting @@ -126,7 +115,7 @@ class ConnectionManager constructor( var primaryError: Throwable? = null configsToTry.forEachIndexed { index, candidate -> if (index > 0) { - AppLog.d(TAG, "Retrying connection with fallback URL ${candidate.url}") + AppLog.d(TAG, "Retrying connection with fallback endpoint") } val result = connectSingle(candidate, password) @@ -146,14 +135,15 @@ class ConnectionManager constructor( val probeResult = runCatching { withTimeout(8_000) { - api.listProjects() + api.listProjects(directory = null, workspace = null) } } if (probeResult.isFailure) { + currentCoroutineContext().ensureActive() val error = probeResult.exceptionOrNull() - AppLog.e(TAG, "Project probe failed", error) - _connectionState.value = ConnectionState.Error(error?.message ?: "Connection failed") + AppLog.e(TAG, "Project probe failed") + _connectionState.value = ConnectionState.Error("Connection failed") return Result.failure(error ?: Exception("Connection failed")) } @@ -172,7 +162,9 @@ class ConnectionManager constructor( val generation = ServerGeneration(++generationCounter) val connection = Connection(config, generation, api, eventSource) - _connection.value = connection + synchronized(connectionLifecycleLock) { + _connection.value = connection + } // Forward SSE connection state instead of setting Connected optimistically. // The state will move from Connecting → Connected when SSE onOpen fires. @@ -181,7 +173,7 @@ class ConnectionManager constructor( eventSource.connectionState.collect { sseState -> // Only forward if this event source is still the active one if (_connection.value?.eventSource === eventSource) { - AppLog.d(TAG, "SSE state forwarded: $sseState") + AppLog.d(TAG, "SSE connection state updated") _connectionState.value = sseState handleSseStateForReconnectOwner(connection, sseState) } @@ -190,12 +182,14 @@ class ConnectionManager constructor( eventSource.connect() - AppLog.d(TAG, "Connected successfully to ${config.url}") + AppLog.d(TAG, "Connected successfully") Result.success(probeResult.getOrNull().orEmpty()) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { - AppLog.e(TAG, "Connection failed", e) + AppLog.e(TAG, "Connection failed") _connection.value = null - _connectionState.value = ConnectionState.Error(e.message ?: "Unknown error") + _connectionState.value = ConnectionState.Error("Connection failed") _authOkHttpClient.value = null Result.failure(e) } @@ -240,13 +234,13 @@ class ConnectionManager constructor( * * Use this for background-resume recovery instead of full connect() which requires password. */ - fun reconnectSse(reason: String = "unknown"): Boolean { + fun reconnectSse(@Suppress("UNUSED_PARAMETER") reason: String = "unknown"): Boolean { val connection = _connection.value if (connection == null) { - AppLog.w(TAG, "reconnectSse($reason) called with no connection – ignoring") + AppLog.w(TAG, "SSE reconnect requested with no connection; ignoring") return false } - AppLog.d(TAG, "reconnectSse($reason) – lightweight SSE restart") + AppLog.d(TAG, "Restarting SSE connection") _connectionState.value = ConnectionState.Connecting connection.eventSource.reconnect() return true @@ -257,12 +251,23 @@ class ConnectionManager constructor( val state = _connectionState.value if (state is ConnectionState.Connected || state is ConnectionState.Connecting) return - AppLog.d(TAG, "Foreground resume: SSE state is $state, refreshing SSE reconnect owner") + AppLog.d(TAG, "Refreshing SSE connection after foreground resume") connection.eventSource.resetConsecutiveErrors() reconnectSse(reason = "app_foreground") } - fun disconnect() { + fun disconnect() = synchronized(connectionLifecycleLock) { + disconnectLocked() + } + + /** Disconnects only if [generation] still owns this manager's live connection. */ + fun disconnect(generation: ServerGeneration): Boolean = synchronized(connectionLifecycleLock) { + if (_connection.value?.generation != generation) return@synchronized false + disconnectLocked() + true + } + + private fun disconnectLocked() { AppLog.d(TAG, "Disconnecting") sseForwardingJob?.cancel() sseForwardingJob = null @@ -281,7 +286,7 @@ class ConnectionManager constructor( sseEscalationJob?.cancel() sseEscalationJob = null } - is ConnectionState.Error -> scheduleSseEscalation(connection, state) + is ConnectionState.Error -> scheduleSseEscalation(connection) ConnectionState.Disconnected -> { sseEscalationJob?.cancel() sseEscalationJob = null @@ -289,7 +294,7 @@ class ConnectionManager constructor( } } - private fun scheduleSseEscalation(connection: Connection, state: ConnectionState.Error) { + private fun scheduleSseEscalation(connection: Connection) { sseEscalationJob?.cancel() sseEscalationJob = scope.launch { val settings = settingsDataStore.connectionSettings.first() @@ -303,7 +308,10 @@ class ConnectionManager constructor( delay(settings.reconnectTimeoutSeconds * 1000L) if (_connection.value === connection && _connectionState.value is ConnectionState.Error) { - AppLog.w(TAG, "SSE remained in Error after ${settings.reconnectTimeoutSeconds}s; escalating to Disconnected: ${state.message}") + AppLog.w( + TAG, + "SSE remained in Error after ${settings.reconnectTimeoutSeconds}s; escalating to Disconnected", + ) connection.eventSource.disconnect() } } @@ -313,19 +321,22 @@ class ConnectionManager constructor( * Build a shared base OkHttpClient with auth and common settings. * Derived clients share its connection pool and dispatcher via newBuilder(). */ - private fun buildBaseOkHttpClient(config: ServerConfig, password: String?): OkHttpClient { + internal fun buildBaseOkHttpClient(config: ServerConfig, password: String?): OkHttpClient { val builder = OkHttpClient.Builder() .connectTimeout(30, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS) + .followRedirects(false) + .followSslRedirects(false) .connectionPool(sharedConnectionPool) .dispatcher(sharedDispatcher) if (config.username != null && password != null) { - builder.addInterceptor(createAuthInterceptor(config.username, password)) + val configuredOrigin = requireNotNull(ServerUrl.normalizeConnectUrl(config.url)?.toHttpUrlOrNull()) + builder.addInterceptor(createAuthInterceptor(config.username, password, configuredOrigin)) } if (config.allowInsecure) { - AppLog.w(TAG, "TLS verification DISABLED for ${config.url} (allowInsecure=true)") + AppLog.w(TAG, "TLS verification DISABLED (allowInsecure=true)") builder.applyInsecureTls() } @@ -335,25 +346,32 @@ class ConnectionManager constructor( private fun buildOkHttpClient(base: OkHttpClient): OkHttpClient = base.newBuilder() .readTimeout(60, TimeUnit.SECONDS) - .addInterceptor( - HttpLoggingInterceptor().apply { - level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.NONE - redactHeader("Authorization") - } - ) + .addInterceptor(createDiagnosticLoggingInterceptor()) .build() private fun buildSseOkHttpClient(base: OkHttpClient): OkHttpClient = base.newBuilder() .readTimeout(0, TimeUnit.SECONDS) - .addInterceptor( - HttpLoggingInterceptor().apply { - level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.HEADERS else HttpLoggingInterceptor.Level.NONE - redactHeader("Authorization") - } - ) + .addInterceptor(createDiagnosticLoggingInterceptor()) .build() + /** + * Debug diagnostics intentionally stop at headers. Provider auth and OAuth endpoints carry + * credentials in JSON bodies, and future endpoints may do the same; a headers-only policy is + * therefore safer than trying to recognize secrets or maintain a sensitive-path allowlist. + */ + internal fun createDiagnosticLoggingInterceptor( + debugLoggingEnabled: Boolean = BuildConfig.DEBUG, + logger: HttpLoggingInterceptor.Logger = HttpLoggingInterceptor.Logger.DEFAULT, + ): HttpLoggingInterceptor = HttpLoggingInterceptor(logger).apply { + level = if (debugLoggingEnabled) { + HttpLoggingInterceptor.Level.HEADERS + } else { + HttpLoggingInterceptor.Level.NONE + } + redactHeader("Authorization") + } + /** * Build an OkHttpClient configured for WebSocket use (long-lived, with ping). * This is exposed to PtyWebSocketClient via [authOkHttpClient]. @@ -364,8 +382,12 @@ class ConnectionManager constructor( .pingInterval(30, TimeUnit.SECONDS) .build() - private fun createAuthInterceptor(username: String, password: String): Interceptor { + internal fun createAuthInterceptor(username: String, password: String, configuredOrigin: HttpUrl): Interceptor { return Interceptor { chain -> + val requestUrl = chain.request().url + if (!requestUrl.hasSameOrigin(configuredOrigin)) { + return@Interceptor chain.proceed(chain.request().newBuilder().removeHeader("Authorization").build()) + } val credentials = Credentials.basic(username, password) val request = chain.request().newBuilder() .header("Authorization", credentials) @@ -384,3 +406,12 @@ class ConnectionManager constructor( .build() } } + +internal fun HttpUrl.hasSameOrigin(other: HttpUrl): Boolean = + scheme.httpEquivalent() == other.scheme.httpEquivalent() && host == other.host && port == other.port + +private fun String.httpEquivalent(): String = when (this) { + "ws" -> "http" + "wss" -> "https" + else -> this +} diff --git a/app/src/main/java/dev/blazelight/p4oc/core/network/MdnsDiscoveryManager.kt b/app/src/main/java/dev/blazelight/p4oc/core/network/MdnsDiscoveryManager.kt index 3acfa32d..4bf30dfb 100644 --- a/app/src/main/java/dev/blazelight/p4oc/core/network/MdnsDiscoveryManager.kt +++ b/app/src/main/java/dev/blazelight/p4oc/core/network/MdnsDiscoveryManager.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withPermit +import okhttp3.Authenticator import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.OkHttpClient import okhttp3.Request @@ -33,6 +34,17 @@ private const val SERVICE_NAME_PREFIX = "opencode-" private const val SEED_PROBE_TIMEOUT_SECONDS = 2L private const val SEED_PROBE_CONCURRENCY = 4 +internal fun buildSeedProbeClient(allowInsecure: Boolean): OkHttpClient = OkHttpClient.Builder() + .connectTimeout(SEED_PROBE_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .readTimeout(SEED_PROBE_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .callTimeout(SEED_PROBE_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .followRedirects(false) + .followSslRedirects(false) + .authenticator(Authenticator.NONE) + .proxyAuthenticator(Authenticator.NONE) + .apply { if (allowInsecure) applyInsecureTls() } + .build() + enum class DiscoverySource { MDNS, SEED, @@ -84,6 +96,7 @@ private fun normalizeSeedUrl(rawUrl: String): String? { val candidate = if (trimmed.contains("://")) trimmed else "http://$trimmed" val parsed = candidate.toHttpUrlOrNull() ?: return null if (parsed.scheme != "http" && parsed.scheme != "https") return null + if (parsed.username.isNotEmpty() || parsed.password.isNotEmpty()) return null val builder = parsed.newBuilder() .query(null) @@ -134,13 +147,13 @@ internal fun mergeDiscoveredServer( val current = existing[existingIndex] val replacement = when { current.source == DiscoverySource.SEED && incoming.source == DiscoverySource.MDNS -> incoming.copy( - allowInsecure = current.allowInsecure || incoming.allowInsecure, + allowInsecure = current.allowInsecure, ) current.source == DiscoverySource.MDNS && incoming.source == DiscoverySource.SEED -> current.copy( - allowInsecure = current.allowInsecure || incoming.allowInsecure, + allowInsecure = current.allowInsecure, ) else -> incoming.copy( - allowInsecure = current.allowInsecure || incoming.allowInsecure, + allowInsecure = current.allowInsecure, ) } @@ -174,22 +187,9 @@ class MdnsDiscoveryManager(private val context: Context) { private var seedProbeJob: Job? = null private var resolveParentJob: Job? = null - private val strictProbeClient: OkHttpClient by lazy { - OkHttpClient.Builder() - .connectTimeout(SEED_PROBE_TIMEOUT_SECONDS, TimeUnit.SECONDS) - .readTimeout(SEED_PROBE_TIMEOUT_SECONDS, TimeUnit.SECONDS) - .callTimeout(SEED_PROBE_TIMEOUT_SECONDS, TimeUnit.SECONDS) - .build() - } + private val strictProbeClient: OkHttpClient by lazy { buildSeedProbeClient(allowInsecure = false) } - private val insecureProbeClient: OkHttpClient by lazy { - OkHttpClient.Builder() - .connectTimeout(SEED_PROBE_TIMEOUT_SECONDS, TimeUnit.SECONDS) - .readTimeout(SEED_PROBE_TIMEOUT_SECONDS, TimeUnit.SECONDS) - .callTimeout(SEED_PROBE_TIMEOUT_SECONDS, TimeUnit.SECONDS) - .applyInsecureTls() - .build() - } + private val insecureProbeClient: OkHttpClient by lazy { buildSeedProbeClient(allowInsecure = true) } private val _discoveredServers = MutableStateFlow>(emptyList()) val discoveredServers: StateFlow> = _discoveredServers.asStateFlow() @@ -220,28 +220,28 @@ class MdnsDiscoveryManager(private val context: Context) { val listener = object : NsdManager.DiscoveryListener { override fun onDiscoveryStarted(serviceType: String) { - AppLog.d(TAG, "Discovery started for $serviceType") + AppLog.d(TAG, "Discovery started") } override fun onServiceFound(serviceInfo: NsdServiceInfo) { val name = serviceInfo.serviceName - AppLog.d(TAG, "Service found: $name") + AppLog.d(TAG, "Service found") if (name.startsWith(SERVICE_NAME_PREFIX, ignoreCase = true)) { - AppLog.d(TAG, "OpenCode service matched: $name, queuing resolve") + AppLog.d(TAG, "OpenCode service matched; queuing resolve") launchResolve(serviceInfo) } } override fun onServiceLost(serviceInfo: NsdServiceInfo) { val name = serviceInfo.serviceName - AppLog.d(TAG, "Service lost: $name") + AppLog.d(TAG, "Service lost") _discoveredServers.update { servers -> servers.filter { it.serviceName != name } } } override fun onDiscoveryStopped(serviceType: String) { - AppLog.d(TAG, "Discovery stopped for $serviceType") + AppLog.d(TAG, "Discovery stopped") } override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) { @@ -261,7 +261,7 @@ class MdnsDiscoveryManager(private val context: Context) { nsdManager.discoverServices(SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, listener) startSeedProbing(seeds) } catch (e: Exception) { - AppLog.e(TAG, "Failed to start discovery", e) + AppLog.e(TAG, "Failed to start discovery: ${e.javaClass.simpleName}") activeListener = null resolveParentJob?.cancel() resolveParentJob = null @@ -285,7 +285,7 @@ class MdnsDiscoveryManager(private val context: Context) { try { nsdManager.stopServiceDiscovery(listener) } catch (e: Exception) { - AppLog.w(TAG, "Error stopping discovery: ${e.message}") + AppLog.w(TAG, "Error stopping discovery: ${e.javaClass.simpleName}") } } @@ -319,7 +319,7 @@ class MdnsDiscoveryManager(private val context: Context) { runCatching { client.newCall(request).execute().use { response -> - if (response.code == 200) { + if (response.code.isOpenCodeSeedResponse()) { val server = DiscoveredServer( serviceName = "seed:${seed.host}:${seed.port}", host = seed.host, @@ -328,14 +328,14 @@ class MdnsDiscoveryManager(private val context: Context) { source = DiscoverySource.SEED, allowInsecure = seed.allowInsecure, ) - AppLog.d(TAG, "Seed probe passed for ${seed.canonicalUrl}") + AppLog.d(TAG, "Seed probe passed") _discoveredServers.update { servers -> mergeDiscoveredServer(servers, server) } } else { - AppLog.d(TAG, "Seed probe failed for ${seed.canonicalUrl}: HTTP ${response.code}") + AppLog.d(TAG, "Seed probe failed: HTTP ${response.code}") } } }.onFailure { error -> - AppLog.d(TAG, "Seed probe failed for ${seed.canonicalUrl}: ${error.message}") + AppLog.d(TAG, "Seed probe failed: ${error.javaClass.simpleName}") } } @@ -346,7 +346,7 @@ class MdnsDiscoveryManager(private val context: Context) { val resolvedInfo = resolveService(serviceInfo) ?: return@withLock val server = resolvedInfo.toDiscoveredServer() ?: return@withLock - AppLog.d(TAG, "Resolved: ${server.serviceName} -> ${server.url}") + AppLog.d(TAG, "Service resolved") _discoveredServers.update { servers -> mergeDiscoveredServer(servers, server) @@ -359,7 +359,7 @@ class MdnsDiscoveryManager(private val context: Context) { return suspendCancellableCoroutine { continuation -> val listener = object : NsdManager.ResolveListener { override fun onResolveFailed(info: NsdServiceInfo, errorCode: Int) { - AppLog.w(TAG, "Resolve failed for ${info.serviceName}: errorCode=$errorCode") + AppLog.w(TAG, "Resolve failed: errorCode=$errorCode") if (continuation.isActive) continuation.resume(null) } @@ -371,7 +371,7 @@ class MdnsDiscoveryManager(private val context: Context) { try { nsdManager.resolveService(serviceInfo, listener) } catch (e: Exception) { - AppLog.e(TAG, "Error resolving service: ${e.message}", e) + AppLog.e(TAG, "Error resolving service: ${e.javaClass.simpleName}") if (continuation.isActive) continuation.resume(null) } } @@ -382,7 +382,7 @@ class MdnsDiscoveryManager(private val context: Context) { val port = port val hostAddress = host?.hostAddress if (hostAddress == null) { - AppLog.w(TAG, "Resolved $serviceName but hostAddress is null, skipping") + AppLog.w(TAG, "Resolved service has no host address; skipping") return null } @@ -403,3 +403,8 @@ class MdnsDiscoveryManager(private val context: Context) { ) } } + +private const val HTTP_OK = 200 +private const val HTTP_UNAUTHORIZED = 401 + +internal fun Int.isOpenCodeSeedResponse(): Boolean = this == HTTP_OK || this == HTTP_UNAUTHORIZED diff --git a/app/src/main/java/dev/blazelight/p4oc/core/network/OpenCodeApi.kt b/app/src/main/java/dev/blazelight/p4oc/core/network/OpenCodeApi.kt index 72fd785c..f18e1851 100644 --- a/app/src/main/java/dev/blazelight/p4oc/core/network/OpenCodeApi.kt +++ b/app/src/main/java/dev/blazelight/p4oc/core/network/OpenCodeApi.kt @@ -10,23 +10,36 @@ interface OpenCodeApi { suspend fun health(): HealthResponse @GET("project") - suspend fun listProjects(): List + suspend fun listProjects( + @Query("directory") directory: String?, + @Query("workspace") workspace: String? + ): List @GET("project/current") - suspend fun getCurrentProject(): ProjectDto + suspend fun getCurrentProject( + @Query("directory") directory: String?, + @Query("workspace") workspace: String? + ): ProjectDto @GET("path") - suspend fun getPath(): PathInfoDto + suspend fun getPath( + @Query("directory") directory: String?, + @Query("workspace") workspace: String? + ): PathInfoDto @GET("vcs") suspend fun getVcsInfo( - @Query("directory") directory: String? + @Query("directory") directory: String?, + @Query("workspace") workspace: String? ): VcsInfoDto @GET("session") + @Suppress("LongParameterList") suspend fun listSessions( @Query("directory") directory: String?, + @Query("workspace") workspace: String?, @Query("scope") scope: String? = null, + @Query("path") path: String? = null, @Query("roots") roots: Boolean? = null, @Query("start") start: Long? = null, @Query("search") search: String? = null, @@ -36,115 +49,134 @@ interface OpenCodeApi { @POST("session") suspend fun createSession( @Query("directory") directory: String?, + @Query("workspace") workspace: String?, @Body request: CreateSessionRequest ): SessionDto @GET("session/{id}") suspend fun getSession( @Path("id") id: String, - @Query("directory") directory: String? + @Query("directory") directory: String?, + @Query("workspace") workspace: String? ): SessionDto @DELETE("session/{id}") suspend fun deleteSession( @Path("id") id: String, - @Query("directory") directory: String? + @Query("directory") directory: String?, + @Query("workspace") workspace: String? ): Boolean @PATCH("session/{id}") suspend fun updateSession( @Path("id") id: String, @Body request: UpdateSessionRequest, - @Query("directory") directory: String? + @Query("directory") directory: String?, + @Query("workspace") workspace: String? ): SessionDto @GET("session/status") suspend fun getSessionStatuses( - @Query("directory") directory: String? + @Query("directory") directory: String?, + @Query("workspace") workspace: String? ): Map @POST("session/{id}/abort") suspend fun abortSession( @Path("id") id: String, - @Query("directory") directory: String? + @Query("directory") directory: String?, + @Query("workspace") workspace: String? ): Response @POST("session/{id}/fork") suspend fun forkSession( @Path("id") id: String, @Body request: ForkSessionRequest, - @Query("directory") directory: String? + @Query("directory") directory: String?, + @Query("workspace") workspace: String? ): SessionDto @GET("session/{id}/children") suspend fun getSessionChildren( @Path("id") id: String, - @Query("directory") directory: String? + @Query("directory") directory: String?, + @Query("workspace") workspace: String? ): List @GET("session/{id}/todo") suspend fun getSessionTodos( @Path("id") id: String, - @Query("directory") directory: String? + @Query("directory") directory: String?, + @Query("workspace") workspace: String? ): List @POST("session/{id}/init") suspend fun initSession( @Path("id") id: String, @Body request: InitSessionRequest, - @Query("directory") directory: String? + @Query("directory") directory: String?, + @Query("workspace") workspace: String? ): Boolean @POST("session/{id}/share") suspend fun shareSession( @Path("id") id: String, - @Query("directory") directory: String? + @Query("directory") directory: String?, + @Query("workspace") workspace: String? ): SessionDto @DELETE("session/{id}/share") suspend fun unshareSession( @Path("id") id: String, - @Query("directory") directory: String? + @Query("directory") directory: String?, + @Query("workspace") workspace: String? ): SessionDto @GET("session/{id}/diff") suspend fun getSessionDiff( @Path("id") id: String, @Query("messageID") messageID: String? = null, - @Query("directory") directory: String? - ): List + @Query("directory") directory: String?, + @Query("workspace") workspace: String? + ): List @POST("session/{id}/summarize") suspend fun summarizeSession( @Path("id") id: String, - @Query("directory") directory: String? + @Query("directory") directory: String?, + @Query("workspace") workspace: String? ): Boolean @POST("session/{id}/revert") suspend fun revertSession( @Path("id") id: String, @Body request: RevertSessionRequest, - @Query("directory") directory: String? + @Query("directory") directory: String?, + @Query("workspace") workspace: String? ): SessionDto @POST("session/{id}/unrevert") suspend fun unrevertSession( @Path("id") id: String, - @Query("directory") directory: String? + @Query("directory") directory: String?, + @Query("workspace") workspace: String? ): SessionDto @GET("session/{sessionId}/message") suspend fun getMessages( @Path("sessionId") sessionId: String, - @Query("limit") limit: Int? = null, - @Query("directory") directory: String? + @Query("limit") limit: Int?, + @Query("before") before: String?, + @Query("directory") directory: String?, + @Query("workspace") workspace: String? ): List @GET("session/{sessionId}/message/{messageId}") suspend fun getMessage( @Path("sessionId") sessionId: String, @Path("messageId") messageId: String, - @Query("directory") directory: String? + @Query("directory") directory: String?, + @Query("workspace") workspace: String? ): MessageWrapperDto /** @@ -156,33 +188,38 @@ interface OpenCodeApi { suspend fun sendMessageAsync( @Path("sessionId") sessionId: String, @Body request: SendMessageRequest, - @Query("directory") directory: String? + @Query("directory") directory: String?, + @Query("workspace") workspace: String? ) @POST("session/{sessionId}/command") suspend fun executeCommand( @Path("sessionId") sessionId: String, @Body request: ExecuteCommandRequest, - @Query("directory") directory: String? + @Query("directory") directory: String?, + @Query("workspace") workspace: String? ): MessageWrapperDto @POST("session/{sessionId}/shell") suspend fun executeShellCommand( @Path("sessionId") sessionId: String, @Body request: ShellCommandRequest, - @Query("directory") directory: String? + @Query("directory") directory: String?, + @Query("workspace") workspace: String? ): MessageWrapperDto @GET("permission") suspend fun listPermissions( - @Query("directory") directory: String? + @Query("directory") directory: String?, + @Query("workspace") workspace: String? ): List @POST("permission/{requestId}/reply") suspend fun respondToPermission( @Path("requestId") requestId: String, @Body request: PermissionResponseRequest, - @Query("directory") directory: String? + @Query("directory") directory: String?, + @Query("workspace") workspace: String? ): Boolean @GET("api/session/{sessionId}/permission") @@ -190,6 +227,24 @@ interface OpenCodeApi { @Path("sessionId") sessionId: String ): PermissionV2RequestListResponseDto + @GET("api/session/{sessionId}/question") + suspend fun listSessionQuestionsV2( + @Path("sessionId") sessionId: String + ): Response + + @POST("api/session/{sessionId}/question/{requestId}/reply") + suspend fun respondToQuestionV2( + @Path("sessionId") sessionId: String, + @Path("requestId") requestId: String, + @Body request: QuestionV2Reply + ): Response + + @POST("api/session/{sessionId}/question/{requestId}/reject") + suspend fun rejectQuestionV2( + @Path("sessionId") sessionId: String, + @Path("requestId") requestId: String + ): Response + @POST("api/session/{sessionId}/permission/{requestId}/reply") suspend fun respondToPermissionV2( @Path("sessionId") sessionId: String, @@ -201,48 +256,56 @@ interface OpenCodeApi { suspend fun respondToQuestion( @Path("requestId") requestId: String, @Body request: QuestionReplyRequest, - @Query("directory") directory: String? - ): Boolean + @Query("directory") directory: String?, + @Query("workspace") workspace: String? + ): Response @POST("question/{requestId}/reject") suspend fun rejectQuestion( @Path("requestId") requestId: String, - @Query("directory") directory: String? - ): Boolean + @Query("directory") directory: String?, + @Query("workspace") workspace: String? + ): Response @GET("question") suspend fun listPendingQuestions( - @Query("directory") directory: String? - ): List + @Query("directory") directory: String?, + @Query("workspace") workspace: String? + ): Response> @GET("command") suspend fun listCommands( - @Query("directory") directory: String? + @Query("directory") directory: String?, + @Query("workspace") workspace: String? ): List @GET("file") suspend fun listFiles( @Query("path") path: String, - @Query("directory") directory: String? + @Query("directory") directory: String?, + @Query("workspace") workspace: String? ): List @GET("file/content") suspend fun readFile( @Query("path") path: String, - @Query("directory") directory: String? + @Query("directory") directory: String?, + @Query("workspace") workspace: String? ): FileContentDto @GET("file/status") suspend fun getFileStatus( - @Query("directory") directory: String? + @Query("directory") directory: String?, + @Query("workspace") workspace: String? ): List - @GET("find") - suspend fun searchText(@Query("pattern") pattern: String): List - @GET("find/file") + @Suppress("LongParameterList") suspend fun searchFiles( @Query("query") query: String, + @Query("directory") directory: String?, + @Query("workspace") workspace: String?, + @Query("dirs") dirs: String? = null, @Query("type") type: String? = null, @Query("limit") limit: Int? = null ): List @@ -250,47 +313,77 @@ interface OpenCodeApi { @GET("find/symbol") suspend fun searchSymbols( @Query("query") query: String, - @Query("directory") directory: String? + @Query("directory") directory: String?, + @Query("workspace") workspace: String? ): List @GET("config") - suspend fun getConfig(): ConfigDto + suspend fun getConfig( + @Query("directory") directory: String?, + @Query("workspace") workspace: String? + ): ConfigDto @PATCH("config") - suspend fun updateConfig(@Body config: ConfigDto): ConfigDto + suspend fun updateConfig( + @Body config: ConfigDto, + @Query("directory") directory: String?, + @Query("workspace") workspace: String? + ): ConfigDto @GET("provider") - suspend fun getProviders(): ProvidersResponseDto + suspend fun getProviders( + @Query("directory") directory: String?, + @Query("workspace") workspace: String? + ): ProvidersResponseDto @GET("provider/auth") - suspend fun getProviderAuthMethods(): Map> + suspend fun getProviderAuthMethods( + @Query("directory") directory: String?, + @Query("workspace") workspace: String? + ): Map> @GET("agent") - suspend fun getAgents(): List - - @POST("model/active") - suspend fun setActiveModel(@Body request: SetActiveModelRequest): Boolean + suspend fun getAgents( + @Query("directory") directory: String?, + @Query("workspace") workspace: String? + ): List @GET("lsp") - suspend fun getLspStatus(): List + suspend fun getLspStatus( + @Query("directory") directory: String?, + @Query("workspace") workspace: String? + ): List @GET("formatter") - suspend fun getFormatterStatus(): List + suspend fun getFormatterStatus( + @Query("directory") directory: String?, + @Query("workspace") workspace: String? + ): List @GET("mcp") - suspend fun getMcpStatus(): Map + suspend fun getMcpStatus( + @Query("directory") directory: String?, + @Query("workspace") workspace: String? + ): Map // ============================================================================ // OAuth & Auth Endpoints (aligned with SDK) // ============================================================================ @POST("provider/{id}/oauth/authorize") - suspend fun authorizeProvider(@Path("id") id: String): ProviderAuthAuthorizationDto + suspend fun authorizeProvider( + @Path("id") id: String, + @Body request: ProviderAuthAuthorizeRequest, + @Query("directory") directory: String?, + @Query("workspace") workspace: String? + ): ProviderAuthAuthorizationDto @POST("provider/{id}/oauth/callback") suspend fun oauthCallback( @Path("id") id: String, - @Body request: OAuthCallbackRequest + @Body request: OAuthCallbackRequest, + @Query("directory") directory: String?, + @Query("workspace") workspace: String? ): Boolean @PUT("auth/{id}") @@ -304,42 +397,70 @@ interface OpenCodeApi { // ============================================================================ @POST("instance/dispose") - suspend fun disposeInstance(): Boolean + suspend fun disposeInstance( + @Query("directory") directory: String?, + @Query("workspace") workspace: String? + ): Boolean // ============================================================================ // MCP Management // ============================================================================ @POST("mcp") - suspend fun addMcpServer(@Body request: AddMcpServerRequest): McpStatusDto + suspend fun addMcpServer( + @Body request: AddMcpServerRequest, + @Query("directory") directory: String?, + @Query("workspace") workspace: String?, + ): Map // ============================================================================ // Logging // ============================================================================ @POST("log") - suspend fun log(@Body request: LogRequest): Boolean + suspend fun log( + @Body request: LogRequest, + @Query("directory") directory: String?, + @Query("workspace") workspace: String? + ): Boolean // ============================================================================ // PTY (Terminal) Endpoints // ============================================================================ @GET("pty") - suspend fun listPtySessions(): List + suspend fun listPtySessions( + @Query("directory") directory: String?, + @Query("workspace") workspace: String?, + ): List @POST("pty") - suspend fun createPtySession(@Body request: CreatePtyRequest): PtyDto + suspend fun createPtySession( + @Query("directory") directory: String?, + @Query("workspace") workspace: String?, + @Body request: CreatePtyRequest, + ): PtyDto @GET("pty/{id}") - suspend fun getPtySession(@Path("id") id: String): PtyDto + suspend fun getPtySession( + @Path("id") id: String, + @Query("directory") directory: String?, + @Query("workspace") workspace: String?, + ): PtyDto @DELETE("pty/{id}") - suspend fun deletePtySession(@Path("id") id: String): Boolean + suspend fun deletePtySession( + @Path("id") id: String, + @Query("directory") directory: String?, + @Query("workspace") workspace: String?, + ): Boolean - @PATCH("pty/{id}") + @PUT("pty/{id}") suspend fun updatePtySession( @Path("id") id: String, - @Body request: UpdatePtyRequest + @Query("directory") directory: String?, + @Query("workspace") workspace: String?, + @Body request: UpdatePtyRequest, ): PtyDto // ============================================================================ @@ -347,10 +468,15 @@ interface OpenCodeApi { // ============================================================================ @GET("experimental/tool/ids") - suspend fun getToolIds(): List + suspend fun getToolIds( + @Query("directory") directory: String?, + @Query("workspace") workspace: String? + ): List @GET("experimental/tool") suspend fun getTools( + @Query("directory") directory: String?, + @Query("workspace") workspace: String?, @Query("provider") provider: String, @Query("model") model: String ): ToolListDto @@ -360,5 +486,8 @@ interface OpenCodeApi { // ============================================================================ @GET("config/providers") - suspend fun getConfigProviders(): ConfigProvidersDto + suspend fun getConfigProviders( + @Query("directory") directory: String?, + @Query("workspace") workspace: String? + ): ConfigProvidersDto } diff --git a/app/src/main/java/dev/blazelight/p4oc/core/network/OpenCodeEventSource.kt b/app/src/main/java/dev/blazelight/p4oc/core/network/OpenCodeEventSource.kt index 771af017..0085d1c1 100644 --- a/app/src/main/java/dev/blazelight/p4oc/core/network/OpenCodeEventSource.kt +++ b/app/src/main/java/dev/blazelight/p4oc/core/network/OpenCodeEventSource.kt @@ -44,6 +44,7 @@ class OpenCodeEventSource( companion object { private const val TAG = "OpenCodeEventSource" private const val MAX_CONSECUTIVE_ERRORS = 15 + internal const val MAX_EVENT_DATA_CHARS = 4 * 1024 * 1024 } private val eventPumpScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) @@ -116,8 +117,6 @@ class OpenCodeEventSource( _connectionState.value = ConnectionState.Disconnected } toClose?.closeSafely() - eventPumpScope.cancel("OpenCodeEventSource shut down") - eventChannel.close() } fun reconnect() { @@ -155,6 +154,8 @@ class OpenCodeEventSource( _connectionState.value = ConnectionState.Disconnected } toClose?.closeSafely() + eventChannel.cancel() + eventPumpScope.cancel("OpenCodeEventSource shut down") } /** @@ -174,13 +175,13 @@ class OpenCodeEventSource( try { close() } catch (e: Exception) { - AppLog.w(TAG, "Error closing BackgroundEventSource: ${e.message}", e) + AppLog.w(TAG, "Error closing BackgroundEventSource: ${e.javaClass.simpleName}") } } private fun createBackgroundEventSource(gen: Long): BackgroundEventSource { val eventUrl = "$baseUrl/global/event" - AppLog.d(TAG, "SSE target URL: $eventUrl") + AppLog.d(TAG, "Creating SSE event source") val connectStrategy = ConnectStrategy.http(URI(eventUrl)) .httpClient(okHttpClient) @@ -195,25 +196,36 @@ class OpenCodeEventSource( return BackgroundEventSource.Builder(handler, eventSourceBuilder) .threadBaseName("OpenCodeSSE") .connectionErrorHandler( - ConnectionErrorHandler { t -> - // Decision-only — do NOT emit events here to avoid duplicates with onError/onClosed. - if (isShutdown || !isActiveGeneration(gen)) { - AppLog.d(TAG, "Connection error after shutdown/stale → SHUTDOWN (${t.message})") - ConnectionErrorHandler.Action.SHUTDOWN - } else { - AppLog.d(TAG, "Connection error, library will retry: ${t.message}") - ConnectionErrorHandler.Action.PROCEED - } - } + ConnectionErrorHandler { t -> connectionErrorAction(t, gen) } ) .build() } + // Decision-only — do NOT emit events here to avoid duplicates with onError/onClosed. + private fun connectionErrorAction(t: Throwable, gen: Long): ConnectionErrorHandler.Action = + if (isShutdown || !isActiveGeneration(gen)) { + AppLog.d(TAG, "Connection error after shutdown/stale → SHUTDOWN (${t.javaClass.simpleName})") + ConnectionErrorHandler.Action.SHUTDOWN + } else if (consecutiveErrors.get() >= MAX_CONSECUTIVE_ERRORS) { + AppLog.w( + TAG, + "Connection error cap reached (${consecutiveErrors.get()}) → SHUTDOWN (${t.javaClass.simpleName})" + ) + ConnectionErrorHandler.Action.SHUTDOWN + } else { + AppLog.d(TAG, "Connection error; library will retry: ${t.javaClass.simpleName}") + ConnectionErrorHandler.Action.PROCEED + } + /** Returns true if [gen] matches the current active generation and we're not shut down. */ private fun isActiveGeneration(gen: Long): Boolean = !isShutdown && generation == gen private fun parseAndEmitEvent(data: String, gen: Long) { + if (data.length > MAX_EVENT_DATA_CHARS) { + rejectOversizedEvent(data.length, gen) + return + } try { val globalEvent = json.decodeFromString(data) val event = eventMapper.mapToEvent(globalEvent.payload) @@ -231,18 +243,29 @@ class OpenCodeEventSource( } } catch (e2: Exception) { if (!data.contains("server.heartbeat") && !data.contains("server.connected")) { - AppLog.e(TAG, "Failed to parse event (${data.length} chars): ${data.take(80)}…", e2) + AppLog.e(TAG, "Failed to parse event (${data.length} chars): ${e2.javaClass.simpleName}") } } } } + private fun rejectOversizedEvent(length: Int, gen: Long) { + AppLog.w(TAG, "Rejecting oversized SSE event ($length chars)") + if (!isActiveGeneration(gen)) return + val error = IllegalStateException("SSE event exceeded the safe size limit") + _connectionState.value = ConnectionState.Error(error.message.orEmpty()) + enqueueEvent(OpenCodeEvent.Error(error), gen) + eventPumpScope.launch { + if (isActiveGeneration(gen)) reconnect() + } + } + private fun enqueueEvent(event: OpenCodeEvent, gen: Long? = null) { val result = eventChannel.trySend( QueuedEvent(event = event, directory = null, includeDirectoryEvent = false, generation = gen) ) if (result.isFailure) { - AppLog.e(TAG, "Failed to queue event: ${event::class.simpleName}", result.exceptionOrNull()) + AppLog.e(TAG, "Failed to queue event: ${event::class.simpleName}") } } @@ -251,7 +274,7 @@ class OpenCodeEventSource( QueuedEvent(event = event, directory = directory, includeDirectoryEvent = true, generation = gen) ) if (result.isFailure) { - AppLog.e(TAG, "Failed to queue mapped event: ${event::class.simpleName}", result.exceptionOrNull()) + AppLog.e(TAG, "Failed to queue mapped event: ${event::class.simpleName}") } } @@ -327,13 +350,12 @@ class OpenCodeEventSource( if (errorCount >= MAX_CONSECUTIVE_ERRORS) { AppLog.e( TAG, - "SSE error (onError): ${t.message}, $errorCount consecutive errors – escalating to Disconnected", - t + "SSE error: ${t.javaClass.simpleName}, $errorCount consecutive errors; disconnecting", ) _connectionState.value = ConnectionState.Disconnected } else { - AppLog.e(TAG, "SSE error (onError): ${t.message}, consecutiveErrors=$errorCount", t) - _connectionState.value = ConnectionState.Error(t.message) + AppLog.e(TAG, "SSE error (onError): ${t.javaClass.simpleName}, consecutiveErrors=$errorCount") + _connectionState.value = ConnectionState.Error("Live updates are temporarily unavailable") } enqueueEvent(OpenCodeEvent.Error(t), gen) } diff --git a/app/src/main/java/dev/blazelight/p4oc/core/network/PtyWebSocketClient.kt b/app/src/main/java/dev/blazelight/p4oc/core/network/PtyWebSocketClient.kt index f8593420..c6d3565a 100644 --- a/app/src/main/java/dev/blazelight/p4oc/core/network/PtyWebSocketClient.kt +++ b/app/src/main/java/dev/blazelight/p4oc/core/network/PtyWebSocketClient.kt @@ -1,6 +1,7 @@ package dev.blazelight.p4oc.core.network import dev.blazelight.p4oc.core.log.AppLog +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -12,24 +13,26 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import okhttp3.OkHttpClient +import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.Request import okhttp3.Response import okhttp3.WebSocket import okhttp3.WebSocketListener -import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger /** * WebSocket client for PTY terminal I/O. * Connects to /pty/{id}/connect endpoint for real-time terminal communication. * - * Auth is handled by the OkHttpClient provided by ConnectionManager, + * Auth is handled by the OkHttpClient resolved from the exact registry-owned server generation, * which has an auth interceptor baked in. This class never sees credentials. */ class PtyWebSocketClient constructor( - private val connectionManager: ConnectionManager + private val serverConnectionRegistry: ServerConnectionRegistry, + private val serverRef: dev.blazelight.p4oc.domain.server.ServerRef, + private val serverGeneration: dev.blazelight.p4oc.domain.server.ServerGeneration, + dispatcher: CoroutineDispatcher = Dispatchers.IO, ) : java.io.Closeable { companion object { private const val TAG = "PtyWebSocketClient" @@ -38,7 +41,7 @@ class PtyWebSocketClient constructor( } private val supervisorJob = SupervisorJob() - private val scope = CoroutineScope(supervisorJob + Dispatchers.IO) + private val scope = CoroutineScope(supervisorJob + dispatcher) private val _connectionState = MutableStateFlow(ConnectionState.Disconnected) val connectionState: StateFlow = _connectionState.asStateFlow() @@ -51,6 +54,8 @@ class PtyWebSocketClient constructor( // Track the last PTY ID for reconnection after background disconnect private var lastPtyId: String? = null + private var lastDirectory: String? = null + private var lastWorkspace: String? = null private val reconnectAttempts = AtomicInteger(0) @Volatile @@ -65,16 +70,6 @@ class PtyWebSocketClient constructor( // Lock to prevent race conditions in connect/disconnect private val connectionLock = Any() - // Fallback OkHttpClient for unauthenticated connections - private val fallbackOkHttpClient: OkHttpClient by lazy { - OkHttpClient.Builder() - .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(0, TimeUnit.SECONDS) - .writeTimeout(30, TimeUnit.SECONDS) - .pingInterval(30, TimeUnit.SECONDS) - .build() - } - sealed class ConnectionState { object Disconnected : ConnectionState() object Connecting : ConnectionState() @@ -82,7 +77,7 @@ class PtyWebSocketClient constructor( data class Error(val message: String) : ConnectionState() } - fun connect(ptyId: String) { + fun connect(ptyId: String, directory: String?, workspace: String?) { synchronized(connectionLock) { // Disconnect from previous session if any if (currentPtyId != null && currentPtyId != ptyId) { @@ -90,37 +85,35 @@ class PtyWebSocketClient constructor( } if (currentWebSocket != null && currentPtyId == ptyId) { - AppLog.d(TAG, "Already connected to $ptyId") + AppLog.d(TAG, "Already connected to PTY") return } - val connection = connectionManager.connection.value - if (connection == null) { - AppLog.e(TAG, "Cannot connect: No active connection") - _connectionState.value = ConnectionState.Error("Not connected to server") + val transport = serverConnectionRegistry.terminalTransport(serverRef, serverGeneration) + if (transport == null) { + AppLog.e(TAG, "Cannot connect: server connection generation is unavailable") + _connectionState.value = ConnectionState.Error("Server connection is no longer available") return } + val connection = transport.connection _connectionState.value = ConnectionState.Connecting currentPtyId = ptyId lastPtyId = ptyId + lastDirectory = directory + lastWorkspace = workspace userDisconnected = false val gen = ++generation - val baseUrl = connection.config.url - // Convert http(s):// to ws(s):// - val wsUrl = baseUrl - .replace("http://", "ws://") - .replace("https://", "wss://") - .trimEnd('/') + "/pty/$ptyId/connect" + val wsUrl = buildPtyWebSocketUrl(connection.config.url, ptyId, directory, workspace) - AppLog.d(TAG, "Connecting to WebSocket: $wsUrl (gen=$gen)") + AppLog.d(TAG, "Connecting PTY WebSocket (gen=$gen)") val request = Request.Builder().url(wsUrl).build() - // Use the auth-aware OkHttpClient from ConnectionManager. + // Use the auth-aware OkHttpClient from the exact registry-owned connection. // The auth interceptor automatically adds Authorization headers. - val wsClient = connectionManager.authOkHttpClient.value ?: fallbackOkHttpClient + val wsClient = transport.authClient currentWebSocket = wsClient.newWebSocket( request, @@ -132,7 +125,7 @@ class PtyWebSocketClient constructor( webSocket.close(1000, "Stale connection") return } - AppLog.d(TAG, "WebSocket connected to $ptyId") + AppLog.d(TAG, "PTY WebSocket connected") reconnectAttempts.set(0) _connectionState.value = ConnectionState.Connected(ptyId) } @@ -140,7 +133,7 @@ class PtyWebSocketClient constructor( override fun onMessage(webSocket: WebSocket, text: String) { if (generation != gen) return - AppLog.v(TAG, "Received: ${text.take(100)}${if (text.length > 100) "..." else ""}") + AppLog.v(TAG, "Received terminal output (${text.length} chars)") // This is a bounded handoff to the terminal collector, not upstream // backpressure to OkHttp. If rendering falls behind far enough to // fill the buffer, drop the frame rather than spawning unbounded work. @@ -150,12 +143,12 @@ class PtyWebSocketClient constructor( } override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { - AppLog.d(TAG, "WebSocket closing: $code $reason") + AppLog.d(TAG, "PTY WebSocket closing (code=$code)") webSocket.close(1000, null) } override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { - AppLog.d(TAG, "WebSocket closed: $code $reason (gen=$gen)") + AppLog.d(TAG, "PTY WebSocket closed (code=$code, gen=$gen)") val ptyIdForReconnect: String? synchronized(connectionLock) { if (generation != gen) { @@ -169,12 +162,12 @@ class PtyWebSocketClient constructor( } // Attempt reconnection if not user-initiated if (!userDisconnected && ptyIdForReconnect != null) { - scheduleReconnect(ptyIdForReconnect) + scheduleReconnect(ptyIdForReconnect, directory, workspace) } } override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { - AppLog.e(TAG, "WebSocket error: ${t.message} (gen=$gen)", t) + AppLog.e(TAG, "PTY WebSocket failure (${t::class.simpleName}, gen=$gen)") val ptyIdForReconnect: String? synchronized(connectionLock) { if (generation != gen) { @@ -184,11 +177,11 @@ class PtyWebSocketClient constructor( currentWebSocket = null ptyIdForReconnect = currentPtyId currentPtyId = null - _connectionState.value = ConnectionState.Error(t.message ?: "Unknown error") + _connectionState.value = ConnectionState.Error("Terminal connection failed") } // Attempt reconnection if not user-initiated if (!userDisconnected && ptyIdForReconnect != null) { - scheduleReconnect(ptyIdForReconnect) + scheduleReconnect(ptyIdForReconnect, directory, workspace) } } } @@ -202,25 +195,25 @@ class PtyWebSocketClient constructor( AppLog.w(TAG, "Cannot send: WebSocket not connected") return false } - AppLog.v(TAG, "Sending: ${data.take(50)}${if (data.length > 50) "..." else ""}") + AppLog.v(TAG, "Sending terminal input (${data.length} chars)") return ws.send(data) } - private fun scheduleReconnect(ptyId: String) { + private fun scheduleReconnect(ptyId: String, directory: String?, workspace: String?) { val attempts = reconnectAttempts.get() if (attempts >= MAX_RECONNECT_ATTEMPTS) { - AppLog.w(TAG, "Max reconnect attempts reached for $ptyId, giving up") + AppLog.w(TAG, "Max PTY reconnect attempts reached; giving up") reconnectAttempts.set(0) return } val delayMs = RECONNECT_DELAYS_MS[attempts.coerceAtMost(RECONNECT_DELAYS_MS.lastIndex)] reconnectAttempts.incrementAndGet() - AppLog.d(TAG, "Scheduling reconnect attempt ${attempts + 1} for $ptyId in ${delayMs}ms") + AppLog.d(TAG, "Scheduling PTY reconnect attempt ${attempts + 1} in ${delayMs}ms") scope.launch { delay(delayMs) if (!userDisconnected && currentWebSocket == null) { - AppLog.d(TAG, "Attempting reconnect to $ptyId (attempt ${reconnectAttempts.get()})") - connect(ptyId) + AppLog.d(TAG, "Attempting PTY reconnect (attempt ${reconnectAttempts.get()})") + connect(ptyId, directory, workspace) } } } @@ -236,13 +229,13 @@ class PtyWebSocketClient constructor( return } if (isConnected() && currentPtyId == ptyId) { - AppLog.d(TAG, "reconnect() called but already connected to $ptyId") + AppLog.d(TAG, "reconnect() called but PTY is already connected") return } - AppLog.d(TAG, "reconnect() to last PTY: $ptyId") + AppLog.d(TAG, "reconnect() to last PTY") userDisconnected = false reconnectAttempts.set(0) - connect(ptyId) + connect(ptyId, lastDirectory, lastWorkspace) } fun disconnect() { @@ -271,3 +264,28 @@ class PtyWebSocketClient constructor( supervisorJob.cancel() } } + +internal fun buildPtyWebSocketUrl( + baseUrl: String, + ptyId: String, + directory: String?, + workspace: String?, +): String { + val httpUrl = baseUrl.trimEnd('/').toHttpUrl() + val webSocketScheme = when (httpUrl.scheme) { + "http" -> "ws" + "https" -> "wss" + else -> error("Unsupported server URL scheme: ${httpUrl.scheme}") + } + val encodedHttpUrl = httpUrl.newBuilder() + .addPathSegment("pty") + .addPathSegment(ptyId) + .addPathSegment("connect") + .apply { + directory?.let { addQueryParameter("directory", it) } + workspace?.let { addQueryParameter("workspace", it) } + } + .build() + // HttpUrl deliberately models only HTTP(S); convert the already safely encoded URL afterward. + return encodedHttpUrl.toString().replaceFirst("${httpUrl.scheme}://", "$webSocketScheme://") +} diff --git a/app/src/main/java/dev/blazelight/p4oc/core/network/ServerConnectionRegistry.kt b/app/src/main/java/dev/blazelight/p4oc/core/network/ServerConnectionRegistry.kt index 279526a0..bf2230ea 100644 --- a/app/src/main/java/dev/blazelight/p4oc/core/network/ServerConnectionRegistry.kt +++ b/app/src/main/java/dev/blazelight/p4oc/core/network/ServerConnectionRegistry.kt @@ -1,25 +1,47 @@ +@file:Suppress("Indentation", "ImportOrdering") + package dev.blazelight.p4oc.core.network import dev.blazelight.p4oc.core.datastore.SavedServer import dev.blazelight.p4oc.core.datastore.SettingsDataStore +import dev.blazelight.p4oc.data.remote.dto.ProjectDto +import dev.blazelight.p4oc.domain.model.OpenCodeEvent +import dev.blazelight.p4oc.domain.server.ScopedEvent +import dev.blazelight.p4oc.domain.server.ServerGeneration import dev.blazelight.p4oc.domain.server.ServerRef +import dev.blazelight.p4oc.domain.server.WorkspaceKey +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient import java.util.concurrent.ConcurrentHashMap +import kotlin.coroutines.coroutineContext /** * Owns connection state per saved server so multi-server tabs do not depend on a - * mutable app-global current server. The existing [ConnectionManager] remains the - * single-server implementation for the active legacy flow; this registry is the - * multi-server coordination surface used by the pinned Home architecture. + * mutable app-global current server. This registry is the sole authority that creates + * and owns live [ConnectionManager] instances. */ +@OptIn(ExperimentalCoroutinesApi::class) class ServerConnectionRegistry constructor( private val settingsDataStore: SettingsDataStore, private val connectionManagerFactory: (ServerConfig) -> ConnectionManager, @@ -27,23 +49,91 @@ class ServerConnectionRegistry constructor( ) { private val states = ConcurrentHashMap>() private val managers = ConcurrentHashMap() - private val connections = ConcurrentHashMap>() + private val connections = ConcurrentHashMap>() + private val generationStates = ConcurrentHashMap>() + private val generationStateLock = Any() private val stateCollectors = ConcurrentHashMap() + private val connectionCollectors = ConcurrentHashMap() + private val eventCollectors = ConcurrentHashMap() + private val connectJobs = ConcurrentHashMap() + private val serverEvents = ConcurrentHashMap>() + private val _scopedEvents = MutableSharedFlow() + + private val staleGenerationState = MutableStateFlow(STALE_GENERATION_ERROR).asStateFlow() + + /** All live events from every registry-owned server connection. */ + val scopedEvents: SharedFlow = _scopedEvents.asSharedFlow() + + /** Stable live event stream for one server, including each reconnect generation. */ + fun events(serverRef: ServerRef): Flow = eventFlow(serverRef.endpointKey).asSharedFlow() fun connectionState(serverRef: ServerRef): StateFlow = stateFlow( serverRef.endpointKey ).asStateFlow() - fun connection(serverRef: ServerRef): StateFlow = connections.getOrPut(serverRef.endpointKey) { - MutableStateFlow(null).asStateFlow() + /** Connection state for an immutable workspace generation. */ + fun connectionState( + serverRef: ServerRef, + generation: ServerGeneration, + ): StateFlow = synchronized(generationStateLock) { + val manager = managers[serverRef.endpointKey] + val activeGeneration = manager?.connection?.value?.generation + if (activeGeneration != generation) return@synchronized staleGenerationState + + generationStates.getOrPut(GenerationKey(serverRef.endpointKey, generation)) { + MutableStateFlow(manager.connectionState.value) + } } + internal fun generationStateCount(): Int = generationStates.size + + fun connection(serverRef: ServerRef): StateFlow = connectionFlow(serverRef.endpointKey).asStateFlow() + fun api(serverRef: ServerRef): OpenCodeApi? = managers[serverRef.endpointKey]?.getApi() + /** + * Resolves the REST API owned by one exact server connection generation. + * A workspace owner must never silently move to a newer connection after reconnect. + */ + fun api(serverRef: ServerRef, generation: dev.blazelight.p4oc.domain.server.ServerGeneration): OpenCodeApi? = + managerForGeneration(serverRef, generation)?.getApi() + + /** Resolves the connection and auth-aware client from the same registry-owned manager. */ + fun terminalTransport( + serverRef: ServerRef, + generation: dev.blazelight.p4oc.domain.server.ServerGeneration, + ): TerminalTransport? { + val manager = managerForGeneration(serverRef, generation) + val connection = manager?.connection?.value + val authClient = manager?.authOkHttpClient?.value + val pairIsCurrent = connection != null && + authClient != null && + manager.connection.value === connection && + connection.generation == generation + return if (pairIsCurrent) TerminalTransport(checkNotNull(connection), checkNotNull(authClient)) else null + } + fun generation(serverRef: ServerRef): dev.blazelight.p4oc.domain.server.ServerGeneration? = managers[serverRef.endpointKey]?.currentGeneration + private fun managerForGeneration( + serverRef: ServerRef, + generation: dev.blazelight.p4oc.domain.server.ServerGeneration, + ): ConnectionManager? = managers[serverRef.endpointKey]?.takeIf { it.currentGeneration == generation } + + /** Starts a registry-owned connection attempt without awaiting its probe result. */ fun connect(server: SavedServer, password: String? = null) { + scope.launch { connectAndAwait(server, password) } + } + + /** + * Connects one explicit server and returns only the result of the attempt that still owns + * this endpoint. A replacement attempt cancels this suspension, so stale successes cannot + * be used by a caller to persist or navigate. + */ + @Suppress("LongMethod", "Indentation") + suspend fun connectAndAwait(server: SavedServer, password: String? = null): Result> = + coroutineScope { val serverRef = server.toServerRef() val state = stateFlow(server.endpointKey) state.value = ConnectionState.Connecting @@ -52,22 +142,80 @@ class ServerConnectionRegistry constructor( } stateCollectors.computeIfAbsent(server.endpointKey) { scope.launch { - manager.connectionState.collect { managerState -> - state.value = managerState + manager.connectionState.collect { + val managerState = manager.connectionState.value + if (managerState !is ConnectionState.Disconnected || state.value !is ConnectionState.Connecting) { + state.value = managerState + } + reconcileGenerationState(server.endpointKey, manager) } } } - connections[server.endpointKey] = manager.connection - scope.launch { + val connection = connectionFlow(server.endpointKey) + connection.value = manager.connection.value + connectionCollectors.computeIfAbsent(server.endpointKey) { + scope.launch { + manager.connection.collect { managerConnection -> + connection.value = managerConnection + reconcileGenerationState(server.endpointKey, manager) + } + } + } + val events = eventFlow(server.endpointKey) + eventCollectors.computeIfAbsent(server.endpointKey) { + scope.launch { + manager.connection.flatMapLatest { activeConnection -> + if (activeConnection == null) { + emptyFlow() + } else { + activeConnection.eventSource.directoryEvents.map { directoryEvent -> + ScopedEvent( + serverRef = serverRef, + generation = activeConnection.generation, + workspaceKey = directoryEvent.directory?.takeIf(String::isNotBlank) + ?.let(WorkspaceKey::Directory) ?: WorkspaceKey.Global, + event = directoryEvent.event, + ) + } + } + }.collect { event -> + events.emit(event) + _scopedEvents.emit(event) + if (event.event is OpenCodeEvent.GlobalDisposed) { + invalidateGeneration(event.serverRef, event.generation) + } + } + } + } + connectJobs.remove(server.endpointKey)?.cancel() + val outcome = CompletableDeferred>>() + val connectJob = launch(start = kotlinx.coroutines.CoroutineStart.LAZY) { val resolvedPassword = password ?: settingsDataStore.getSavedServerPassword(server) val result = manager.connect(server.toServerConfig(), resolvedPassword) - state.value = result.fold( - onSuccess = { manager.connectionState.value }, - onFailure = { ConnectionState.Error(it.message ?: "Connection failed") }, - ) - if (state.value is ConnectionState.Connecting) { - state.value = ConnectionState.Connected + coroutineContext.ensureActive() + val stillOwned = connectJobs[server.endpointKey] === coroutineContext[Job] && + managers[server.endpointKey] === manager + if (!stillOwned) throw CancellationException("Connection attempt was replaced") + if (result.isSuccess) { + val connectedKey = manager.connection.value?.config?.url?.let(ServerUrl::endpointKey) + if (connectedKey != server.endpointKey) { + throw CancellationException("Connection attempt no longer owns the requested server") + } + } else { + state.value = ConnectionState.Error("Connection failed") } + outcome.complete(result) + } + connectJobs[server.endpointKey] = connectJob + connectJob.invokeOnCompletion { cause -> + connectJobs.remove(server.endpointKey, connectJob) + if (cause != null) outcome.cancel(cause as? CancellationException) + } + connectJob.start() + try { + outcome.await() + } finally { + if (connectJobs.remove(server.endpointKey, connectJob)) connectJob.cancel() } } @@ -77,30 +225,113 @@ class ServerConnectionRegistry constructor( } fun disconnect(serverRef: ServerRef) { - stateCollectors.remove(serverRef.endpointKey)?.cancel() - managers.remove(serverRef.endpointKey)?.disconnect() - connections.remove(serverRef.endpointKey) + connectJobs.remove(serverRef.endpointKey)?.cancel() + stateCollectors.remove(serverRef.endpointKey).let { collector -> + if (collector != null) collector.cancel() + } + connectionCollectors.remove(serverRef.endpointKey).let { collector -> + if (collector != null) collector.cancel() + } + eventCollectors.remove(serverRef.endpointKey)?.cancel() + connectionFlow(serverRef.endpointKey).value = null stateFlow(serverRef.endpointKey).value = ConnectionState.Disconnected + staleAndEvictGenerationStates(serverRef.endpointKey) + managers.remove(serverRef.endpointKey).let { manager -> + if (manager != null) manager.disconnect() + } + } + + /** + * Applies a server-global disposal only to the connection generation that emitted it. + * A delayed event from an old SSE stream must not tear down its replacement. + */ + @Suppress("ReturnCount") + internal fun invalidateGeneration(serverRef: ServerRef, generation: ServerGeneration): Boolean { + val endpointKey = serverRef.endpointKey + val manager = managers[endpointKey] ?: return false + if (!manager.disconnect(generation)) return false + if (!managers.remove(endpointKey, manager)) return false + + connectJobs.remove(endpointKey)?.cancel() + stateCollectors.remove(endpointKey)?.cancel() + connectionCollectors.remove(endpointKey)?.cancel() + eventCollectors.remove(endpointKey)?.cancel() + connectionFlow(endpointKey).value = null + stateFlow(endpointKey).value = ConnectionState.Disconnected + staleAndEvictGenerationStates(endpointKey) + return true } suspend fun reconnectAll(openTabServers: Set) { val savedByKey = settingsDataStore.getSavedServers().associateBy { it.endpointKey } - openTabServers.forEach { serverRef -> - val saved = savedByKey[serverRef.endpointKey] ?: return@forEach + for (serverRef in openTabServers) { + val saved = savedByKey[serverRef.endpointKey] ?: continue connect(saved, settingsDataStore.getSavedServerPassword(saved)) } } + /** Gives every currently owned connection one opportunity to recover after foregrounding. */ + fun onAppForegrounded() { + managers.values.toList().forEach(ConnectionManager::onAppForegrounded) + } + private fun stateFlow(endpointKey: String): MutableStateFlow = states.getOrPut(endpointKey) { MutableStateFlow(ConnectionState.Disconnected) } + + private fun connectionFlow(endpointKey: String): MutableStateFlow = + connections.getOrPut(endpointKey) { MutableStateFlow(null) } + + private fun eventFlow(endpointKey: String): MutableSharedFlow = + serverEvents.getOrPut(endpointKey) { MutableSharedFlow() } + + private fun reconcileGenerationState(endpointKey: String, manager: ConnectionManager) { + synchronized(generationStateLock) { + val activeGeneration = manager.connection.value?.generation + val iterator = generationStates.entries.iterator() + while (iterator.hasNext()) { + val entry = iterator.next() + if (entry.key.endpointKey == endpointKey && entry.key.generation != activeGeneration) { + entry.value.value = STALE_GENERATION_ERROR + iterator.remove() + } + } + if (activeGeneration != null) { + generationStates[GenerationKey(endpointKey, activeGeneration)]?.value = manager.connectionState.value + } + } + } + + private fun staleAndEvictGenerationStates(endpointKey: String) { + synchronized(generationStateLock) { + val iterator = generationStates.entries.iterator() + while (iterator.hasNext()) { + val entry = iterator.next() + if (entry.key.endpointKey == endpointKey) { + entry.value.value = STALE_GENERATION_ERROR + iterator.remove() + } + } + } + } + + private data class GenerationKey(val endpointKey: String, val generation: ServerGeneration) + + private companion object { + val STALE_GENERATION_ERROR = ConnectionState.Error("Server connection generation is no longer available") + } } +data class TerminalTransport( + val connection: Connection, + val authClient: OkHttpClient, +) + fun SavedServer.toServerRef(): ServerRef = ServerRef.fromEndpointKey(endpointKey, displayName) fun SavedServer.toServerConfig(): ServerConfig = ServerConfig( url = endpoint, name = displayName, - isLocal = endpoint.contains("localhost") || endpoint.contains("127.0.0.1"), + isLocal = endpoint.toHttpUrlOrNull()?.host in setOf("localhost", "127.0.0.1", "::1"), username = username, allowInsecure = allowInsecure, ) diff --git a/app/src/main/java/dev/blazelight/p4oc/core/network/ServerUrl.kt b/app/src/main/java/dev/blazelight/p4oc/core/network/ServerUrl.kt index 0b757e00..ccf9f84d 100644 --- a/app/src/main/java/dev/blazelight/p4oc/core/network/ServerUrl.kt +++ b/app/src/main/java/dev/blazelight/p4oc/core/network/ServerUrl.kt @@ -1,6 +1,8 @@ package dev.blazelight.p4oc.core.network import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import java.net.Inet6Address +import java.net.InetAddress object ServerUrl { const val DEFAULT_PORT = 4096 @@ -26,6 +28,51 @@ object ServerUrl { ) } + /** + * Cleartext authentication is permitted only for exact localhost or literal local-network + * addresses. Other hostnames are deliberately excluded: resolving one here would make the + * decision vulnerable to DNS changes and rebinding between validation and connection. + */ + @Suppress("ReturnCount") + fun allowsCleartextCredentials(input: String): Boolean { + val trimmed = input.trim() + val candidate = if (trimmed.contains("://")) trimmed else "http://$trimmed" + val parsed = stripIpv6ZoneId(candidate).toHttpUrlOrNull() ?: return false + if (parsed.scheme != "http") return true + + val host = parsed.host.substringBefore('%') + return host.equals("localhost", ignoreCase = true) || + isPrivateIpv4Literal(host) || isPrivateIpv6Literal(host) + } + + @Suppress("MagicNumber", "ReturnCount") + private fun isPrivateIpv4Literal(host: String): Boolean { + val octets = host.split('.') + if (octets.size != 4) return false + val values = octets.map { part -> + if (part.isEmpty() || part.any { !it.isDigit() }) return false + part.toIntOrNull()?.takeIf { it in 0..255 } ?: return false + } + return values[0] == 127 || + values[0] == 10 || + (values[0] == 172 && values[1] in 16..31) || + (values[0] == 192 && values[1] == 168) + } + + @Suppress("MagicNumber", "ReturnCount") + private fun isPrivateIpv6Literal(host: String): Boolean { + if (':' !in host || host.any { it !in "0123456789abcdefABCDEF:." }) return false + val address = runCatching { InetAddress.getByName(host) }.getOrNull() as? Inet6Address + ?: return false + val bytes = address.address + val first = bytes[0].toInt() and 0xff + val second = bytes[1].toInt() and 0xff + val isLoopback = bytes.dropLast(1).all { it.toInt() == 0 } && bytes.last().toInt() == 1 + val isUniqueLocal = first and 0xfe == 0xfc + val isLinkLocal = first == 0xfe && second and 0xc0 == 0x80 + return isLoopback || isUniqueLocal || isLinkLocal + } + private data class ParsedServerUrl( val scheme: String, val formattedHost: String, diff --git a/app/src/main/java/dev/blazelight/p4oc/core/notification/NotificationEventObserver.kt b/app/src/main/java/dev/blazelight/p4oc/core/notification/NotificationEventObserver.kt index 3af45bd6..612d8d3f 100644 --- a/app/src/main/java/dev/blazelight/p4oc/core/notification/NotificationEventObserver.kt +++ b/app/src/main/java/dev/blazelight/p4oc/core/notification/NotificationEventObserver.kt @@ -7,16 +7,14 @@ import dev.blazelight.p4oc.core.datastore.NotificationSettings import dev.blazelight.p4oc.core.datastore.SettingsDataStore import dev.blazelight.p4oc.core.haptic.HapticFeedback import dev.blazelight.p4oc.core.log.AppLog -import dev.blazelight.p4oc.core.network.ConnectionManager +import dev.blazelight.p4oc.core.network.ServerConnectionRegistry import dev.blazelight.p4oc.domain.model.OpenCodeEvent import dev.blazelight.p4oc.domain.model.SessionStatus +import dev.blazelight.p4oc.domain.server.ScopedEvent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.launch /** @@ -28,7 +26,7 @@ import kotlinx.coroutines.launch * notifications are NOT guaranteed. */ class NotificationEventObserver constructor( - private val connectionManager: ConnectionManager, + private val serverConnectionRegistry: ServerConnectionRegistry, private val notificationHelper: NotificationHelper, private val settingsDataStore: SettingsDataStore, private val hapticFeedback: HapticFeedback, @@ -44,7 +42,7 @@ class NotificationEventObserver constructor( @Volatile private var cachedSettings = NotificationSettings() - private val busySessions = mutableSetOf() + private val completionTracker = CompletionTracker() fun start() { ProcessLifecycleOwner.get().lifecycle.addObserver(this) @@ -59,6 +57,7 @@ class NotificationEventObserver constructor( override fun onStart(owner: LifecycleOwner) { isInForeground = true + completionTracker.clear() notificationHelper.clearNotifications() } @@ -74,65 +73,112 @@ class NotificationEventObserver constructor( } } - @OptIn(ExperimentalCoroutinesApi::class) private fun observeEvents() { scope.launch { - connectionManager.connection - .filterNotNull() - .flatMapLatest { it.eventSource.events } - .collect { event -> - if (!isInForeground) { - handleEventInBackground(event) - } - } + serverConnectionRegistry.scopedEvents.collect { event -> + handleEvent(event) + } } } - private fun handleEventInBackground(event: OpenCodeEvent) { - if (!cachedSettings.enabled) return - - when (event) { - is OpenCodeEvent.PermissionRequested -> { - if (!cachedSettings.permissionRequests) return - AppLog.d(TAG, "Permission requested in background: ${event.permission.type}") - notificationHelper.showPermissionNotification( - sessionId = event.permission.sessionID, - permission = event.permission - ) - } - is OpenCodeEvent.QuestionAsked -> { - if (!cachedSettings.questions) return - val firstQuestion = event.request.questions.firstOrNull()?.question - AppLog.d(TAG, "Question asked in background") - notificationHelper.showQuestionNotification( - sessionId = event.request.sessionID, - question = firstQuestion - ) - } - is OpenCodeEvent.SessionStatusChanged -> { - val isBusy = event.status is SessionStatus.Busy || event.status is SessionStatus.Retry - if (isBusy) { - busySessions.add(event.sessionID) - } else if (busySessions.remove(event.sessionID)) { - showCompletionFeedback(event.sessionID) - } - } - is OpenCodeEvent.SessionIdle -> { - if (busySessions.remove(event.sessionID)) { - showCompletionFeedback(event.sessionID) - } - } + private fun handleEvent(scopedEvent: ScopedEvent) { + when (val event = scopedEvent.event) { + is OpenCodeEvent.PermissionRequested -> handlePermission(scopedEvent, event) + is OpenCodeEvent.QuestionAsked -> handleQuestion(scopedEvent, event) + is OpenCodeEvent.SessionStatusChanged -> handleStatus(scopedEvent, event) + is OpenCodeEvent.SessionIdle -> complete(scopedEvent.route(event.sessionID)) + is OpenCodeEvent.Disconnected -> completionTracker.clearServer(scopedEvent.serverRef) + OpenCodeEvent.GlobalDisposed -> completionTracker.clearServer(scopedEvent.serverRef) + is OpenCodeEvent.ServerInstanceDisposed -> completionTracker.clearWorkspace( + scopedEvent.serverRef, + scopedEvent.workspaceKey, + ) else -> {} } } - private fun showCompletionFeedback(sessionId: String) { - hapticFeedback.vibrate(cachedSettings.vibrationPattern) + private fun handlePermission(scopedEvent: ScopedEvent, event: OpenCodeEvent.PermissionRequested) { + if (isInForeground || !cachedSettings.enabled || !cachedSettings.permissionRequests) return + AppLog.d(TAG, "Permission requested in background") + notificationHelper.showPermissionNotification( + sessionId = event.permission.sessionID, + serverRef = scopedEvent.serverRef, + workspaceKey = scopedEvent.workspaceKey, + permission = event.permission, + ) + } + + private fun handleQuestion(scopedEvent: ScopedEvent, event: OpenCodeEvent.QuestionAsked) { + if (isInForeground || !cachedSettings.enabled || !cachedSettings.questions) return + AppLog.d(TAG, "Question asked in background") + notificationHelper.showQuestionNotification( + sessionId = event.request.sessionID, + serverRef = scopedEvent.serverRef, + workspaceKey = scopedEvent.workspaceKey, + question = event.request.questions.firstOrNull()?.question, + ) + } + + private fun handleStatus(scopedEvent: ScopedEvent, event: OpenCodeEvent.SessionStatusChanged) { + val route = scopedEvent.route(event.sessionID) + if (event.status is SessionStatus.Busy || event.status is SessionStatus.Retry) { + completionTracker.markBusy(route) + } else { + complete(route) + } + } + + private fun complete(route: NotificationRoute) { + if (completionTracker.complete(route)) showCompletionFeedbackIfBackground(route) + } + + private fun showCompletionFeedbackIfBackground(route: NotificationRoute) { + if (shouldEmitCompletionFeedback(cachedSettings, isInForeground)) showCompletionFeedback(route) + } + + private fun showCompletionFeedback(route: NotificationRoute) { if (cachedSettings.notifyOnCompletion) { + hapticFeedback.vibrate(cachedSettings.vibrationPattern) notificationHelper.showCompletionNotification( - sessionId = sessionId, + sessionId = route.sessionId, + serverRef = route.serverRef, + workspaceKey = route.workspaceKey, sessionTitle = null, ) } } + + private fun ScopedEvent.route(sessionId: String) = NotificationRoute( + sessionId = sessionId, + serverRef = serverRef, + workspaceKey = workspaceKey, + ) +} + +internal fun shouldEmitCompletionFeedback(settings: NotificationSettings, isInForeground: Boolean): Boolean = + settings.enabled && settings.notifyOnCompletion && !isInForeground + +internal class CompletionTracker { + private val busySessions = mutableSetOf() + + fun markBusy(route: NotificationRoute) { + busySessions.add(route) + } + + fun complete(route: NotificationRoute): Boolean = busySessions.remove(route) + + fun clear() { + busySessions.clear() + } + + fun clearServer(serverRef: dev.blazelight.p4oc.domain.server.ServerRef) { + busySessions.removeAll { it.serverRef == serverRef } + } + + fun clearWorkspace( + serverRef: dev.blazelight.p4oc.domain.server.ServerRef, + workspaceKey: dev.blazelight.p4oc.domain.server.WorkspaceKey, + ) { + busySessions.removeAll { it.serverRef == serverRef && it.workspaceKey == workspaceKey } + } } diff --git a/app/src/main/java/dev/blazelight/p4oc/core/notification/NotificationHelper.kt b/app/src/main/java/dev/blazelight/p4oc/core/notification/NotificationHelper.kt index 9641d139..b845fe60 100644 --- a/app/src/main/java/dev/blazelight/p4oc/core/notification/NotificationHelper.kt +++ b/app/src/main/java/dev/blazelight/p4oc/core/notification/NotificationHelper.kt @@ -5,6 +5,7 @@ import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent +import android.net.Uri import android.os.Build import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat @@ -12,10 +13,34 @@ import dev.blazelight.p4oc.MainActivity import dev.blazelight.p4oc.R import dev.blazelight.p4oc.core.log.AppLog import dev.blazelight.p4oc.domain.model.Permission +import dev.blazelight.p4oc.domain.server.ServerRef +import dev.blazelight.p4oc.domain.server.WorkspaceKey import dev.blazelight.p4oc.ui.permission.PermissionDisplayFormatter private const val TAG = "NotificationHelper" +internal const val MAX_NOTIFICATION_TEXT_CODE_POINTS = 512 +private const val NOTIFICATION_TEXT_ELLIPSIS = "…" + +internal fun boundedNotificationText(text: String?, fallback: String): String { + val resolved = text ?: fallback + var index = 0 + var codePoints = 0 + var lastCodePointStart = 0 + + while (index < resolved.length && codePoints < MAX_NOTIFICATION_TEXT_CODE_POINTS) { + lastCodePointStart = index + index += Character.charCount(Character.codePointAt(resolved, index)) + codePoints++ + } + + return if (index == resolved.length) { + resolved + } else { + resolved.substring(0, lastCodePointStart) + NOTIFICATION_TEXT_ELLIPSIS + } +} + class NotificationHelper constructor( private val context: Context ) { @@ -23,18 +48,9 @@ class NotificationHelper constructor( const val CHANNEL_ID = "user_input_required" const val COMPLETION_CHANNEL_ID = "assistant_completed" - private const val PERMISSION_ID_MASK = 0x40000000 - private const val QUESTION_ID_MASK = 0x20000000 - private const val COMPLETION_ID_MASK = 0x10000000 - - private fun permissionNotificationId(sessionId: String): Int = - (sessionId.hashCode() and 0x0FFFFFFF) or PERMISSION_ID_MASK - - private fun questionNotificationId(sessionId: String): Int = - (sessionId.hashCode() and 0x0FFFFFFF) or QUESTION_ID_MASK - - private fun completionNotificationId(sessionId: String): Int = - (sessionId.hashCode() and 0x0FFFFFFF) or COMPLETION_ID_MASK + private const val PERMISSION_NOTIFICATION_ID = 0x40000000 + private const val QUESTION_NOTIFICATION_ID = 0x20000000 + private const val COMPLETION_NOTIFICATION_ID = 0x10000000 } init { @@ -67,24 +83,33 @@ class NotificationHelper constructor( } } - fun showPermissionNotification(sessionId: String, permission: Permission) { - val notificationId = permissionNotificationId(sessionId) + fun showPermissionNotification( + sessionId: String, + serverRef: ServerRef, + workspaceKey: WorkspaceKey, + permission: Permission, + ) { + val route = NotificationRoute(sessionId, serverRef, workspaceKey) + val identity = NotificationRouteCodec.identity(NotificationKind.Permission, route) val intent = Intent(context, MainActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP - putExtra("sessionId", sessionId) - putExtra("type", "permission") + flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP + data = Uri.parse(identity) + NotificationRouteCodec.write(this, route) } val pendingIntent = PendingIntent.getActivity( context, - notificationId, + PERMISSION_NOTIFICATION_ID, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) - val title = PermissionDisplayFormatter.title(context, permission) + val title = boundedNotificationText( + PermissionDisplayFormatter.title(context, permission), + context.getString(R.string.notification_permission_required), + ) val notification = NotificationCompat.Builder(context, CHANNEL_ID) - .setSmallIcon(R.drawable.ic_launcher_foreground) + .setSmallIcon(R.drawable.ic_notification) .setContentTitle(context.getString(R.string.notification_permission_required)) .setContentText(title) .setPriority(NotificationCompat.PRIORITY_HIGH) @@ -93,31 +118,40 @@ class NotificationHelper constructor( .build() try { - NotificationManagerCompat.from(context).notify(notificationId, notification) + NotificationManagerCompat.from(context).notify(identity, PERMISSION_NOTIFICATION_ID, notification) } catch (e: SecurityException) { - AppLog.w(TAG, "Notification post failed: ${e.message}", e) + AppLog.w(TAG, "Notification post failed (${e::class.simpleName})") } } - fun showQuestionNotification(sessionId: String, question: String?) { - val notificationId = questionNotificationId(sessionId) + fun showQuestionNotification( + sessionId: String, + serverRef: ServerRef, + workspaceKey: WorkspaceKey, + question: String?, + ) { + val route = NotificationRoute(sessionId, serverRef, workspaceKey) + val identity = NotificationRouteCodec.identity(NotificationKind.Question, route) val intent = Intent(context, MainActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP - putExtra("sessionId", sessionId) - putExtra("type", "question") + flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP + data = Uri.parse(identity) + NotificationRouteCodec.write(this, route) } val pendingIntent = PendingIntent.getActivity( context, - notificationId, + QUESTION_NOTIFICATION_ID, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) - val questionText = question ?: context.getString(R.string.notification_question_fallback) + val questionText = boundedNotificationText( + question, + context.getString(R.string.notification_question_fallback), + ) val notification = NotificationCompat.Builder(context, CHANNEL_ID) - .setSmallIcon(R.drawable.ic_launcher_foreground) + .setSmallIcon(R.drawable.ic_notification) .setContentTitle(context.getString(R.string.notification_question_title)) .setContentText(questionText) .setStyle(NotificationCompat.BigTextStyle().bigText(questionText)) @@ -127,31 +161,41 @@ class NotificationHelper constructor( .build() try { - NotificationManagerCompat.from(context).notify(notificationId, notification) + NotificationManagerCompat.from(context).notify(identity, QUESTION_NOTIFICATION_ID, notification) } catch (e: SecurityException) { - AppLog.w(TAG, "Notification post failed: ${e.message}", e) + AppLog.w(TAG, "Notification post failed (${e::class.simpleName})") } } - fun showCompletionNotification(sessionId: String, sessionTitle: String?) { - val notificationId = completionNotificationId(sessionId) + fun showCompletionNotification( + sessionId: String, + serverRef: ServerRef, + workspaceKey: WorkspaceKey, + sessionTitle: String?, + ) { + val route = NotificationRoute(sessionId, serverRef, workspaceKey) + val identity = NotificationRouteCodec.identity(NotificationKind.Completion, route) val intent = Intent(context, MainActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP - putExtra("sessionId", sessionId) - putExtra("type", "completion") + flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP + data = Uri.parse(identity) + NotificationRouteCodec.write(this, route) } val pendingIntent = PendingIntent.getActivity( context, - notificationId, + COMPLETION_NOTIFICATION_ID, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) + val sessionText = boundedNotificationText( + sessionTitle, + context.getString(R.string.notification_completion_fallback), + ) val notification = NotificationCompat.Builder(context, COMPLETION_CHANNEL_ID) - .setSmallIcon(R.drawable.ic_launcher_foreground) + .setSmallIcon(R.drawable.ic_notification) .setContentTitle(context.getString(R.string.notification_completion_title)) - .setContentText(sessionTitle ?: context.getString(R.string.notification_completion_fallback)) + .setContentText(sessionText) .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setAutoCancel(true) .setOnlyAlertOnce(true) @@ -159,9 +203,9 @@ class NotificationHelper constructor( .build() try { - NotificationManagerCompat.from(context).notify(notificationId, notification) + NotificationManagerCompat.from(context).notify(identity, COMPLETION_NOTIFICATION_ID, notification) } catch (e: SecurityException) { - AppLog.w(TAG, "Notification post failed: ${e.message}", e) + AppLog.w(TAG, "Notification post failed (${e::class.simpleName})") } } diff --git a/app/src/main/java/dev/blazelight/p4oc/core/notification/NotificationRoute.kt b/app/src/main/java/dev/blazelight/p4oc/core/notification/NotificationRoute.kt new file mode 100644 index 00000000..f7d551bd --- /dev/null +++ b/app/src/main/java/dev/blazelight/p4oc/core/notification/NotificationRoute.kt @@ -0,0 +1,111 @@ +package dev.blazelight.p4oc.core.notification + +import android.content.Intent +import dev.blazelight.p4oc.domain.server.ServerRef +import dev.blazelight.p4oc.domain.server.WorkspaceKey +import dev.blazelight.p4oc.domain.session.SessionId +import java.util.Base64 + +data class NotificationRoute( + val sessionId: String, + val serverRef: ServerRef, + val workspaceKey: WorkspaceKey, +) + +enum class NotificationKind(val wireName: String) { + Permission("permission"), + Question("question"), + Completion("completion"), +} + +object NotificationRouteCodec { + private const val EXTRA_SESSION_ID = "notification.sessionId" + private const val EXTRA_SERVER_ENDPOINT_KEY = "notification.serverEndpointKey" + private const val EXTRA_WORKSPACE_TYPE = "notification.workspaceType" + private const val EXTRA_WORKSPACE_VALUE = "notification.workspaceValue" + private const val WORKSPACE_GLOBAL = "global" + private const val WORKSPACE_DIRECTORY = "directory" + private const val WORKSPACE_SESSION = "session" + + /** + * A complete, injective PendingIntent identity. Android ignores extras when matching + * PendingIntents, so the immutable route and notification kind also live in the data URI. + * The URI is identity only: [read] deliberately validates the explicit extras instead. + */ + fun identity(kind: NotificationKind, route: NotificationRoute): String { + val (workspaceType, workspaceValue) = when (val workspace = route.workspaceKey) { + WorkspaceKey.Global -> WORKSPACE_GLOBAL to "" + is WorkspaceKey.Directory -> WORKSPACE_DIRECTORY to workspace.value + is WorkspaceKey.SessionScoped -> WORKSPACE_SESSION to workspace.sessionId.value + } + return listOf( + "p4oc-internal://notification/v1", + kind.wireName, + encodeIdentityPart(route.serverRef.endpointKey), + workspaceType, + encodeIdentityPart(workspaceValue), + encodeIdentityPart(route.sessionId), + ).joinToString("/") + } + + private fun encodeIdentityPart(value: String): String = + Base64.getUrlEncoder().withoutPadding().encodeToString(value.toByteArray(Charsets.UTF_8)) + + fun write(intent: Intent, route: NotificationRoute) { + intent.putExtra(EXTRA_SESSION_ID, route.sessionId) + intent.putExtra(EXTRA_SERVER_ENDPOINT_KEY, route.serverRef.endpointKey) + when (val workspace = route.workspaceKey) { + WorkspaceKey.Global -> intent.putExtra(EXTRA_WORKSPACE_TYPE, WORKSPACE_GLOBAL) + is WorkspaceKey.Directory -> { + intent.putExtra(EXTRA_WORKSPACE_TYPE, WORKSPACE_DIRECTORY) + intent.putExtra(EXTRA_WORKSPACE_VALUE, workspace.value) + } + is WorkspaceKey.SessionScoped -> { + intent.putExtra(EXTRA_WORKSPACE_TYPE, WORKSPACE_SESSION) + intent.putExtra(EXTRA_WORKSPACE_VALUE, workspace.sessionId.value) + } + } + } + + fun read(intent: Intent?): NotificationRoute? { + intent ?: return null + return decode( + sessionId = intent.getStringExtra(EXTRA_SESSION_ID), + endpointKey = intent.getStringExtra(EXTRA_SERVER_ENDPOINT_KEY), + workspaceType = intent.getStringExtra(EXTRA_WORKSPACE_TYPE), + workspaceValue = intent.getStringExtra(EXTRA_WORKSPACE_VALUE), + ) + } + + /** Removes the notification payload after its route has been handled. */ + fun clear(intent: Intent?) { + intent ?: return + intent.removeExtra(EXTRA_SESSION_ID) + intent.removeExtra(EXTRA_SERVER_ENDPOINT_KEY) + intent.removeExtra(EXTRA_WORKSPACE_TYPE) + intent.removeExtra(EXTRA_WORKSPACE_VALUE) + } + + @Suppress("ReturnCount") + internal fun decode( + sessionId: String?, + endpointKey: String?, + workspaceType: String?, + workspaceValue: String?, + ): NotificationRoute? { + val ownedSessionId = sessionId?.takeIf(String::isNotBlank) ?: return null + val ownedEndpointKey = endpointKey?.takeIf(String::isNotBlank) ?: return null + val workspaceKey = when (workspaceType) { + WORKSPACE_GLOBAL -> WorkspaceKey.Global + WORKSPACE_DIRECTORY -> workspaceValue?.takeIf(String::isNotBlank)?.let(WorkspaceKey::Directory) + WORKSPACE_SESSION -> workspaceValue?.takeIf(String::isNotBlank) + ?.let { WorkspaceKey.SessionScoped(SessionId(it)) } + else -> null + } ?: return null + return NotificationRoute( + sessionId = ownedSessionId, + serverRef = ServerRef.fromEndpointKey(ownedEndpointKey), + workspaceKey = workspaceKey, + ) + } +} diff --git a/app/src/main/java/dev/blazelight/p4oc/core/security/CredentialStore.kt b/app/src/main/java/dev/blazelight/p4oc/core/security/CredentialStore.kt index 5c1cee85..38d05e3b 100644 --- a/app/src/main/java/dev/blazelight/p4oc/core/security/CredentialStore.kt +++ b/app/src/main/java/dev/blazelight/p4oc/core/security/CredentialStore.kt @@ -4,6 +4,8 @@ import android.content.Context import android.content.SharedPreferences import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey +import java.io.IOException +import java.security.GeneralSecurityException /** * Encrypted credential storage backed by EncryptedSharedPreferences. @@ -15,26 +17,59 @@ import androidx.security.crypto.MasterKey * This is the SOLE authority for password storage. No passwords should be * persisted in DataStore, ServerConfig, or RecentServer JSON. */ -class CredentialStore(context: Context) { +class CredentialStore private constructor( + private val prefs: SharedPreferences, +) { + + constructor(context: Context) : this( + createWithRecovery( + create = { createEncryptedPreferences(context) }, + reset = { context.deleteSharedPreferences(FILE_NAME) }, + ), + ) + + internal constructor( + context: Context, + createPreferences: (Context) -> SharedPreferences, + deletePreferences: (Context) -> Unit, + ) : this( + createWithRecovery( + create = { createPreferences(context) }, + reset = { deletePreferences(context) }, + ), + ) companion object { private const val FILE_NAME = "p4oc_credentials" private const val KEY_ACTIVE_PASSWORD = "active_password" private fun serverPasswordKey(url: String): String = "server_password:$url" - } - private val prefs: SharedPreferences = run { - val masterKey = MasterKey.Builder(context) - .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) - .build() - - EncryptedSharedPreferences.create( - context, - FILE_NAME, - masterKey, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM - ) + private fun createEncryptedPreferences(context: Context): SharedPreferences { + val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + return EncryptedSharedPreferences.create( + context, + FILE_NAME, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + } + + private fun createWithRecovery( + create: () -> SharedPreferences, + reset: () -> Unit, + ): SharedPreferences = try { + create() + } catch (_: GeneralSecurityException) { + reset() + create() + } catch (_: IOException) { + reset() + create() + } } // ── Active connection password ────────────────────────────────────── diff --git a/app/src/main/java/dev/blazelight/p4oc/data/files/FilePathValidator.kt b/app/src/main/java/dev/blazelight/p4oc/data/files/FilePathValidator.kt index f3cc7a74..cd9cf7aa 100644 --- a/app/src/main/java/dev/blazelight/p4oc/data/files/FilePathValidator.kt +++ b/app/src/main/java/dev/blazelight/p4oc/data/files/FilePathValidator.kt @@ -59,7 +59,7 @@ internal object FilePathValidator { private fun invalid(message: String): Result = Result.failure(InvalidFilePathException(message)) private val WINDOWS_DRIVE_PATTERN = Regex("^[A-Za-z]:.*") - private val URI_SCHEME_PATTERN = Regex("^[A-Za-z][A-Za-z0-9+.-]*:.*") + private val URI_SCHEME_PATTERN = Regex("^[A-Za-z][A-Za-z0-9+.-]*:/+.*") } internal class InvalidFilePathException(message: String) : IllegalArgumentException(message) diff --git a/app/src/main/java/dev/blazelight/p4oc/data/files/ofish/OfishCapabilities.kt b/app/src/main/java/dev/blazelight/p4oc/data/files/ofish/OfishCapabilities.kt index d6daf650..4e5aac80 100644 --- a/app/src/main/java/dev/blazelight/p4oc/data/files/ofish/OfishCapabilities.kt +++ b/app/src/main/java/dev/blazelight/p4oc/data/files/ofish/OfishCapabilities.kt @@ -9,6 +9,8 @@ internal data class OfishCapabilities( val hasRm: Boolean = false, val hasAwk: Boolean = false, val hasMktemp: Boolean = false, + val hasChmod: Boolean = false, + val modeCommand: ModeCommand? = null, ) { val supportsMutation: Boolean get() = hasBase64 && @@ -18,7 +20,14 @@ internal data class OfishCapabilities( hasMkdir && hasRm && hasAwk && - hasMktemp + hasMktemp && + hasChmod && + modeCommand != null +} + +internal enum class ModeCommand(val wireName: String) { + STAT_GNU("stat -c %a"), + STAT_BSD("stat -f %Lp"), } internal enum class HashCommand(val wireName: String) { diff --git a/app/src/main/java/dev/blazelight/p4oc/data/files/ofish/OfishCapabilityParser.kt b/app/src/main/java/dev/blazelight/p4oc/data/files/ofish/OfishCapabilityParser.kt index 0a6f7dfa..49621b19 100644 --- a/app/src/main/java/dev/blazelight/p4oc/data/files/ofish/OfishCapabilityParser.kt +++ b/app/src/main/java/dev/blazelight/p4oc/data/files/ofish/OfishCapabilityParser.kt @@ -58,6 +58,8 @@ internal object OfishCapabilityParser { hasRm = values["rm"].toBooleanFlag(), hasAwk = values["awk"].toBooleanFlag(), hasMktemp = values["mktemp"].toBooleanFlag(), + hasChmod = values["chmod"].toBooleanFlag(), + modeCommand = values["mode"].toModeCommand(), ) } @@ -70,6 +72,8 @@ internal object OfishCapabilityParser { if (!capabilities.hasRm) add("rm") if (!capabilities.hasAwk) add("awk") if (!capabilities.hasMktemp) add("mktemp") + if (!capabilities.hasChmod) add("chmod") + if (capabilities.modeCommand == null) add("stat_mode") } private fun String?.toBooleanFlag(): Boolean = this == "1" || equals("true", ignoreCase = true) @@ -82,4 +86,10 @@ internal object OfishCapabilityParser { HashCommand.MD5SUM.wireName -> HashCommand.MD5SUM else -> null } + + private fun String?.toModeCommand(): ModeCommand? = when (this?.trim()) { + ModeCommand.STAT_GNU.wireName -> ModeCommand.STAT_GNU + ModeCommand.STAT_BSD.wireName -> ModeCommand.STAT_BSD + else -> null + } } diff --git a/app/src/main/java/dev/blazelight/p4oc/data/files/ofish/OfishCapabilityProbe.kt b/app/src/main/java/dev/blazelight/p4oc/data/files/ofish/OfishCapabilityProbe.kt index 6565b5e3..e08b19a0 100644 --- a/app/src/main/java/dev/blazelight/p4oc/data/files/ofish/OfishCapabilityProbe.kt +++ b/app/src/main/java/dev/blazelight/p4oc/data/files/ofish/OfishCapabilityProbe.kt @@ -47,4 +47,18 @@ internal object OfishShellOutputExtractor { part.state?.error?.takeIf { it.isNotBlank() }?.let { appendLine(it) } } } + + fun extractMutationSegment(message: MessageWrapperDto, expectedMarker: String): String? { + fun String.containsMarker(): Boolean = lineSequence().any { it == expectedMarker } + + // Shell tool state is authoritative. Text parts are only a compatibility fallback for + // servers that return command output as assistant text rather than structured tool state. + val stateSegments = message.parts.flatMap { part -> + listOfNotNull(part.state?.output, part.state?.raw, part.state?.error) + } + return stateSegments.lastOrNull { it.containsMarker() } + ?: message.parts.asReversed().firstNotNullOfOrNull { part -> + part.text?.takeIf { it.containsMarker() } + } + } } diff --git a/app/src/main/java/dev/blazelight/p4oc/data/files/ofish/OfishCapabilityProbeCommand.kt b/app/src/main/java/dev/blazelight/p4oc/data/files/ofish/OfishCapabilityProbeCommand.kt index 10d2fecc..180fb17b 100644 --- a/app/src/main/java/dev/blazelight/p4oc/data/files/ofish/OfishCapabilityProbeCommand.kt +++ b/app/src/main/java/dev/blazelight/p4oc/data/files/ofish/OfishCapabilityProbeCommand.kt @@ -39,9 +39,18 @@ internal object OfishCapabilityProbeCommand { has_rm=0; have rm && has_rm=1 || missing="${'$'}missing rm" has_awk=0; have awk && has_awk=1 || missing="${'$'}missing awk" has_mktemp=0; have mktemp && has_mktemp=1 || missing="${'$'}missing mktemp" + has_chmod=0; have chmod && has_chmod=1 || missing="${'$'}missing chmod" + mode="" + if have stat && stat -c '%a' . >/dev/null 2>&1; then + mode="stat -c %a" + elif have stat && stat -f '%Lp' . >/dev/null 2>&1; then + mode="stat -f %Lp" + else + missing="${'$'}missing stat_mode" + fi - printf 'caps base64=%s base64_decode=%s hash=%s mv=%s mkdir=%s rm=%s awk=%s mktemp=%s\n' \ - "${'$'}base64_present" "${'$'}base64_decode" "${'$'}hash" "${'$'}has_mv" "${'$'}has_mkdir" "${'$'}has_rm" "${'$'}has_awk" "${'$'}has_mktemp" + printf 'caps base64=%s base64_decode=%s hash=%s mv=%s mkdir=%s rm=%s awk=%s mktemp=%s chmod=%s mode=%s\n' \ + "${'$'}base64_present" "${'$'}base64_decode" "${'$'}hash" "${'$'}has_mv" "${'$'}has_mkdir" "${'$'}has_rm" "${'$'}has_awk" "${'$'}has_mktemp" "${'$'}has_chmod" "${'$'}mode" if [ -n "${'$'}missing" ]; then printf '### 501 caps_missing%s\n' "${'$'}missing" diff --git a/app/src/main/java/dev/blazelight/p4oc/data/files/ofish/OfishCommandBuilder.kt b/app/src/main/java/dev/blazelight/p4oc/data/files/ofish/OfishCommandBuilder.kt index 9eff4048..b1807c2a 100644 --- a/app/src/main/java/dev/blazelight/p4oc/data/files/ofish/OfishCommandBuilder.kt +++ b/app/src/main/java/dev/blazelight/p4oc/data/files/ofish/OfishCommandBuilder.kt @@ -15,6 +15,7 @@ internal class OfishCommandBuilder { appendLine("printf '#OFISH_HASH\\n'") appendLine("P=${shellSingleQuote(path)}") appendHashFunction(requireHashCommand(capabilities)) + appendSymlinkGuard("P") append( """ if [ ! -f "${'$'}P" ]; then printf '### 404 missing\n'; exit 0; fi @@ -38,7 +39,10 @@ internal class OfishCommandBuilder { val delimiter = PAYLOAD_DELIMITER appendCommonHeader(marker = "#OFISH_WRITE", path = path, parent = parent, expectedHash = expectedHash) appendHashFunction(requireHashCommand(capabilities)) + appendSymlinkGuard("P") + appendDirectoryGuard() appendExpectedHashGuard() + appendModeCapture(capabilities) append( """ mkdir -p -- "${'$'}D" || { printf '### 500 failed reason=mkdir\n'; exit 0; } @@ -58,6 +62,8 @@ internal class OfishCommandBuilder { append( """ if [ ${'$'}? -ne 0 ]; then printf '### 500 failed reason=decode\n'; exit 0; fi + if [ -n "${'$'}MODE" ]; then chmod "${'$'}MODE" "${'$'}TMP" || { printf '### 500 failed reason=chmod\n'; exit 0; }; fi + if [ -L "${'$'}P" ]; then printf '### 412 precondition reason=symlink\n'; exit 0; fi mv -f -- "${'$'}TMP" "${'$'}P" || { printf '### 500 failed reason=mv\n'; exit 0; } trap - EXIT INT TERM HASH=${'$'}(hash_file "${'$'}P") @@ -78,8 +84,10 @@ internal class OfishCommandBuilder { appendLine("P=${shellSingleQuote(path)}") append( """ + if [ -L "${'$'}P" ]; then printf '### 412 precondition reason=symlink\n'; exit 0; fi if [ ! -e "${'$'}P" ]; then printf '### 404 missing\n'; exit 0; fi if [ -d "${'$'}P" ]; then printf '### 412 precondition reason=directory\n'; exit 0; fi + if [ -L "${'$'}P" ]; then printf '### 412 precondition reason=symlink\n'; exit 0; fi rm -f -- "${'$'}P" || { printf '### 500 failed reason=rm\n'; exit 0; } printf '### 204 deleted\n' exit 0 @@ -94,7 +102,9 @@ internal class OfishCommandBuilder { appendLine("P=${shellSingleQuote(path)}") append( """ + if [ -L "${'$'}P" ]; then printf '### 412 precondition reason=symlink\n'; exit 0; fi if [ -e "${'$'}P" ]; then printf '### 409 conflict\n'; exit 0; fi + if [ -L "${'$'}P" ]; then printf '### 412 precondition reason=symlink\n'; exit 0; fi mkdir -p -- "${'$'}P" || { printf '### 500 failed reason=mkdir\n'; exit 0; } printf '### 201 created\n' exit 0 @@ -111,9 +121,11 @@ internal class OfishCommandBuilder { appendLine("D=${shellSingleQuote(parentDirectory(toPath))}") append( """ + if [ -L "${'$'}FROM" ] || [ -L "${'$'}TO" ]; then printf '### 412 precondition reason=symlink\n'; exit 0; fi if [ ! -e "${'$'}FROM" ]; then printf '### 404 missing\n'; exit 0; fi if [ -e "${'$'}TO" ]; then printf '### 409 conflict\n'; exit 0; fi mkdir -p -- "${'$'}D" || { printf '### 500 failed reason=mkdir\n'; exit 0; } + if [ -L "${'$'}FROM" ] || [ -L "${'$'}TO" ]; then printf '### 412 precondition reason=symlink\n'; exit 0; fi mv -- "${'$'}FROM" "${'$'}TO" || { printf '### 500 failed reason=mv\n'; exit 0; } printf '### 200 ok\n' exit 0 @@ -131,6 +143,7 @@ internal class OfishCommandBuilder { val parent = parentDirectory(path) appendCommonHeader(marker = "#OFISH_UPLOAD_INIT", path = path, parent = parent, expectedHash = expectedHash) appendHashFunction(requireHashCommand(capabilities)) + appendSymlinkGuard("P") appendExpectedHashGuard() append( """ @@ -155,7 +168,9 @@ internal class OfishCommandBuilder { appendLine("TMP=${shellSingleQuote(uploadToken)}") append( """ + if [ -L "${'$'}TMP" ]; then printf '### 412 precondition reason=symlink\n'; exit 0; fi if [ ! -f "${'$'}TMP" ]; then printf '### 412 precondition reason=missing_tmp\n'; exit 0; fi + if [ -L "${'$'}TMP" ]; then printf '### 412 precondition reason=symlink\n'; exit 0; fi base64 ${base64DecodeFlag(capabilities)} >> "${'$'}TMP" <<'$delimiter' """.trimIndent() ) @@ -189,10 +204,15 @@ internal class OfishCommandBuilder { ) appendLine("TMP=${shellSingleQuote(uploadToken)}") appendHashFunction(requireHashCommand(capabilities)) + appendSymlinkGuard("P") appendExpectedHashGuard() + appendModeCapture(capabilities) append( """ + if [ -L "${'$'}TMP" ]; then printf '### 412 precondition reason=symlink\n'; exit 0; fi if [ ! -f "${'$'}TMP" ]; then printf '### 412 precondition reason=missing_tmp\n'; exit 0; fi + if [ -n "${'$'}MODE" ]; then chmod "${'$'}MODE" "${'$'}TMP" || { printf '### 500 failed reason=chmod\n'; exit 0; }; fi + if [ -L "${'$'}P" ] || [ -L "${'$'}TMP" ]; then printf '### 412 precondition reason=symlink\n'; exit 0; fi mv -f -- "${'$'}TMP" "${'$'}P" || { printf '### 500 failed reason=mv\n'; exit 0; } HASH=${'$'}(hash_file "${'$'}P") printf '### 200 ok hash=%s\n' "${'$'}HASH" @@ -254,6 +274,7 @@ internal class OfishCommandBuilder { append( """ if [ -n "${'$'}EXPECTED" ]; then + if [ -L "${'$'}P" ]; then printf '### 412 precondition reason=symlink\n'; exit 0; fi if [ ! -f "${'$'}P" ]; then printf '### 404 missing\n'; exit 0; fi ACTUAL=${'$'}(hash_file "${'$'}P") if [ "${'$'}ACTUAL" != "${'$'}EXPECTED" ]; then @@ -266,6 +287,29 @@ internal class OfishCommandBuilder { append('\n') } + private fun StringBuilder.appendModeCapture(capabilities: OfishCapabilities) { + val command = when (capabilities.modeCommand) { + ModeCommand.STAT_GNU -> "stat -c '%a'" + ModeCommand.STAT_BSD -> "stat -f '%Lp'" + null -> error("OFISH mutation command requires a mode command") + } + require(capabilities.hasChmod) { "OFISH mutation command requires chmod" } + appendLine("MODE=''") + appendSymlinkGuard("P") + appendLine( + "if [ -e \"${'$'}P\" ]; then MODE=${'$'}($command \"${'$'}P\") || " + + "{ printf '### 500 failed reason=mode\\n'; exit 0; }; fi", + ) + } + + private fun StringBuilder.appendDirectoryGuard() { + appendLine("if [ -d \"${'$'}P\" ]; then printf '### 412 precondition reason=directory\\n'; exit 0; fi") + } + + private fun StringBuilder.appendSymlinkGuard(variable: String) { + appendLine("if [ -L \"${'$'}$variable\" ]; then printf '### 412 precondition reason=symlink\\n'; exit 0; fi") + } + private fun requireHashCommand(capabilities: OfishCapabilities): HashCommand = requireNotNull(capabilities.hashCommand) { "OFISH mutation command requires a hash command" } diff --git a/app/src/main/java/dev/blazelight/p4oc/data/files/ofish/OfishMutationClient.kt b/app/src/main/java/dev/blazelight/p4oc/data/files/ofish/OfishMutationClient.kt index 3878087a..9688e695 100644 --- a/app/src/main/java/dev/blazelight/p4oc/data/files/ofish/OfishMutationClient.kt +++ b/app/src/main/java/dev/blazelight/p4oc/data/files/ofish/OfishMutationClient.kt @@ -8,12 +8,17 @@ import dev.blazelight.p4oc.data.files.FileUploadResult import dev.blazelight.p4oc.data.files.FileWriteRequest import dev.blazelight.p4oc.data.files.FileWriteResult import dev.blazelight.p4oc.data.remote.dto.ShellCommandRequest +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import java.io.ByteArrayInputStream import java.io.InputStream +internal const val MAX_UPLOAD_SOURCE_BYTES = 1L * 1024 * 1024 * 1024 +internal const val UPLOAD_TOO_LARGE_MESSAGE = "File is too large to upload (maximum 1 GiB)" + internal fun interface UploadChunkBytesProvider { suspend fun get(capabilities: OfishCapabilities): Int } @@ -28,6 +33,7 @@ internal class FixedUploadChunkBytesProvider( override suspend fun get(capabilities: OfishCapabilities): Int = bytes } +@Suppress("LongParameterList") internal class OfishMutationClient( private val client: OfishWorkspaceClient, private val sessionFactory: OfishSessionFactory, @@ -35,8 +41,13 @@ internal class OfishMutationClient( private val commandBuilder: OfishCommandBuilder = OfishCommandBuilder(), private val shellAgent: String = DEFAULT_SHELL_AGENT, private val uploadChunkBytes: UploadChunkBytesProvider = FixedUploadChunkBytesProvider(OFISH_DEFAULT_CHUNK_BYTES), + private val maxUploadSourceBytes: Long = MAX_UPLOAD_SOURCE_BYTES, ) { + init { + require(maxUploadSourceBytes >= 0) { "max upload source bytes must not be negative" } + } + suspend fun mutationCapabilities(): OfishProbeResult = capabilityCache.get() /** @@ -52,11 +63,11 @@ internal class OfishMutationClient( val capabilities = availableCapabilities().getOrNull() ?: return null return runCatching { sessionFactory.withSession(OPERATION_HASH) { session -> - val status = execute(session.id, commandBuilder.hash(normalizedPath, capabilities)) + val status = execute(session.id, commandBuilder.hash(normalizedPath, capabilities), MARKER_HASH) if (status is OfishMutationStatus.Ok) status.hash else null } }.getOrElse { error -> - AppLog.w(TAG, "OFISH baseline hash failed for $path: ${error.message}") + AppLog.w(TAG, "OFISH baseline hash failed: ${error.javaClass.simpleName}") null } } @@ -65,16 +76,37 @@ internal class OfishMutationClient( val path = normalizeMutationPath(request.path).getOrElse { error -> return FileOperationResult.Failed(error.message ?: INVALID_PATH_MESSAGE, error) } + val contentBytes = request.content.toByteArray(Charsets.UTF_8) + if (contentBytes.size >= OFISH_CHUNKED_WRITE_THRESHOLD_BYTES) { + return uploadFile( + FileUploadRequest( + path = path, + contentLength = contentBytes.size.toLong(), + openStream = { ByteArrayInputStream(contentBytes) }, + expectedHash = request.expectedHash, + ), + ).toWriteResult() + } val capabilities = availableCapabilities().getOrElse { error -> return FileOperationResult.Failed(error.message ?: UNAVAILABLE_MESSAGE, error) } return runCatching { sessionFactory.withSession(OPERATION_WRITE) { session -> - execute(session.id, commandBuilder.write(path, request.content, request.expectedHash, capabilities)) + execute( + session.id, + commandBuilder.write(path, request.content, request.expectedHash, capabilities), + MARKER_WRITE, + ) .toWriteResult(path) } - }.getOrElse { error -> FileOperationResult.Failed("OFISH write failed", error) } + }.fold( + onSuccess = { it }, + onFailure = { error -> + if (error is CancellationException) throw error + FileOperationResult.Failed("OFISH write failed", error) + }, + ) } suspend fun deleteFile(path: String): FileOperationResult { @@ -87,7 +119,7 @@ internal class OfishMutationClient( return runCatching { sessionFactory.withSession(OPERATION_DELETE) { session -> - execute(session.id, commandBuilder.delete(normalizedPath)).toDeleteResult() + execute(session.id, commandBuilder.delete(normalizedPath), MARKER_DELETE).toDeleteResult() } }.getOrElse { error -> FileOperationResult.Failed("OFISH delete failed", error) } } @@ -99,7 +131,7 @@ internal class OfishMutationClient( return runCatching { sessionFactory.withSession(OPERATION_MKDIR) { session -> - execute(session.id, commandBuilder.mkdir(normalizedPath)).toCreateDirectoryResult() + execute(session.id, commandBuilder.mkdir(normalizedPath), MARKER_MKDIR).toCreateDirectoryResult() } }.getOrElse { error -> FileOperationResult.Failed("OFISH folder creation failed", error) } } @@ -112,12 +144,19 @@ internal class OfishMutationClient( return runCatching { sessionFactory.withSession(OPERATION_RENAME) { session -> - execute(session.id, commandBuilder.rename(normalizedFromPath, normalizedToPath)).toRenameResult() + execute( + session.id, + commandBuilder.rename(normalizedFromPath, normalizedToPath), + MARKER_RENAME, + ).toRenameResult() } }.getOrElse { error -> FileOperationResult.Failed("OFISH rename failed", error) } } suspend fun uploadFile(request: FileUploadRequest): FileOperationResult { + if (request.contentLength > maxUploadSourceBytes) { + return FileOperationResult.Failed(UPLOAD_TOO_LARGE_MESSAGE) + } val path = normalizeMutationPath(request.path).getOrElse { error -> return FileOperationResult.Failed(error.message ?: INVALID_PATH_MESSAGE, error) } @@ -129,38 +168,54 @@ internal class OfishMutationClient( sessionFactory.withSession(OPERATION_UPLOAD) { session -> uploadInSession(session.id, path, request, capabilities) } - }.getOrElse { error -> FileOperationResult.Failed("OFISH upload failed", error) } + }.fold( + onSuccess = { it }, + onFailure = { error -> + if (error is CancellationException) throw error + FileOperationResult.Failed("OFISH upload failed", error) + }, + ) } + @Suppress("CyclomaticComplexMethod", "LongMethod", "NestedBlockDepth", "ReturnCount") private suspend fun uploadInSession( sessionId: String, path: String, request: FileUploadRequest, capabilities: OfishCapabilities, ): FileOperationResult { - val initStatus = execute(sessionId, commandBuilder.uploadInit(path, request.expectedHash, capabilities)) + val initStatus = execute( + sessionId, + commandBuilder.uploadInit(path, request.expectedHash, capabilities), + MARKER_UPLOAD_INIT, + ) val uploadToken = when (initStatus) { is OfishMutationStatus.Ok -> initStatus.uploadToken else -> return initStatus.toUploadResult(path) } ?: return FileOperationResult.Failed("Malformed OFISH upload init response: missing upload token") - validateUploadToken(uploadToken, path).getOrElse { error -> - return FileOperationResult.Failed(error.message ?: "Unsafe OFISH upload token", error) - } - var finished = false try { + validateUploadToken(uploadToken, path).getOrElse { error -> + return FileOperationResult.Failed(error.message ?: "Unsafe OFISH upload token", error) + } val chunkBytes = uploadChunkBytes.get(capabilities) require(chunkBytes > 0) { "upload chunk size must be greater than zero" } + var uploaded = 0L request.openStream().use { stream -> - var uploaded = 0L while (true) { - val chunk = stream.readChunk(chunkBytes) + val remaining = maxUploadSourceBytes - uploaded + val readLimit = minOf(chunkBytes.toLong(), remaining + 1L).toInt() + val chunk = stream.readChunk(readLimit) if (chunk.isEmpty()) break + if (chunk.size.toLong() > remaining) { + return FileOperationResult.Failed(UPLOAD_TOO_LARGE_MESSAGE) + } uploaded += chunk.size when ( val chunkStatus = execute( sessionId, - commandBuilder.uploadChunk(uploadToken, chunk, capabilities) + commandBuilder.uploadChunk(uploadToken, chunk, capabilities), + MARKER_UPLOAD_CHUNK, ) ) { is OfishMutationStatus.Ok -> Unit @@ -169,20 +224,31 @@ internal class OfishMutationClient( request.onBytesUploaded?.invoke(uploaded) } } + if (request.contentLength >= 0 && uploaded != request.contentLength) { + val mismatchMessage = "OFISH upload length mismatch: " + + "expected ${request.contentLength} bytes, streamed $uploaded bytes" + return FileOperationResult.Failed( + mismatchMessage + ) + } val finishStatus = - execute(sessionId, commandBuilder.uploadFinish(path, uploadToken, request.expectedHash, capabilities)) + execute( + sessionId, + commandBuilder.uploadFinish(path, uploadToken, request.expectedHash, capabilities), + MARKER_UPLOAD_FINISH, + ) val result = finishStatus.toUploadResult(path) if (result is FileOperationResult.Ok) finished = true return result } finally { if (!finished) { withContext(NonCancellable) { - runCatching { execute(sessionId, commandBuilder.uploadAbort(uploadToken)) } + runCatching { execute(sessionId, commandBuilder.uploadAbort(uploadToken), MARKER_UPLOAD_ABORT) } .onFailure { error -> AppLog.w( TAG, - "Failed to abort OFISH upload temp file: ${error.message}" + "Failed to abort OFISH upload temp file: ${error.javaClass.simpleName}" ) } } @@ -229,7 +295,11 @@ internal class OfishMutationClient( return Result.success(normalized) } - private suspend fun execute(sessionId: String, command: String): OfishMutationStatus { + private suspend fun execute( + sessionId: String, + command: String, + expectedMarker: String, + ): OfishMutationStatus { val response = client.executeShellCommand( sessionId = sessionId, request = ShellCommandRequest( @@ -238,7 +308,11 @@ internal class OfishMutationClient( command = command, ), ) - return OfishMutationParser.parse(OfishShellOutputExtractor.extract(response)) + val output = OfishShellOutputExtractor.extractMutationSegment(response, expectedMarker) + ?: return OfishMutationStatus.Malformed( + "Malformed OFISH mutation output: missing $expectedMarker output segment" + ) + return OfishMutationParser.parse(output, expectedMarker) } private suspend fun availableCapabilities(): Result = when (val result = capabilityCache.get()) { @@ -257,8 +331,13 @@ internal class OfishMutationClient( while (offset < maxBytes) { val read = read(buffer, offset, maxBytes - offset) if (read < 0) break - if (read == 0) continue - offset += read + if (read == 0) { + val nextByte = read() + if (nextByte < 0) break + buffer[offset++] = nextByte.toByte() + } else { + offset += read + } } return if (offset == buffer.size) buffer else buffer.copyOf(offset) } @@ -278,6 +357,13 @@ internal class OfishMutationClient( OfishMutationStatus.Deleted -> FileOperationResult.Failed("Unexpected OFISH write delete status") } + private fun FileOperationResult.toWriteResult(): FileOperationResult = + when (this) { + is FileOperationResult.Ok -> FileOperationResult.Ok(FileWriteResult(path = data.path, hash = data.hash)) + is FileOperationResult.Conflict -> this + is FileOperationResult.Failed -> this + } + private fun OfishMutationStatus.toDeleteResult(): FileOperationResult = when (this) { OfishMutationStatus.Deleted -> FileOperationResult.Ok(Unit) OfishMutationStatus.Missing -> FileOperationResult.Failed("File does not exist") @@ -359,7 +445,17 @@ internal class OfishMutationClient( const val OPERATION_RENAME = "rename" const val OPERATION_UPLOAD = "upload" const val OPERATION_HASH = "hash" + const val MARKER_HASH = "#OFISH_HASH" + const val MARKER_WRITE = "#OFISH_WRITE" + const val MARKER_DELETE = "#OFISH_DELETE" + const val MARKER_MKDIR = "#OFISH_MKDIR" + const val MARKER_RENAME = "#OFISH_RENAME" + const val MARKER_UPLOAD_INIT = "#OFISH_UPLOAD_INIT" + const val MARKER_UPLOAD_CHUNK = "#OFISH_UPLOAD_CHUNK" + const val MARKER_UPLOAD_FINISH = "#OFISH_UPLOAD_FINISH" + const val MARKER_UPLOAD_ABORT = "#OFISH_UPLOAD_ABORT" const val UPLOAD_TOKEN_PREFIX = ".ofish.upload." + const val OFISH_CHUNKED_WRITE_THRESHOLD_BYTES = 32 * 1024 } } @@ -367,6 +463,8 @@ internal open class CachedOfishCapabilities( private val probe: OfishCapabilityProbe, ) { private val mutex = Mutex() + + @Volatile private var cached: OfishProbeResult? = null open suspend fun get(): OfishProbeResult { diff --git a/app/src/main/java/dev/blazelight/p4oc/data/files/ofish/OfishMutationParser.kt b/app/src/main/java/dev/blazelight/p4oc/data/files/ofish/OfishMutationParser.kt index b9929b92..83273b36 100644 --- a/app/src/main/java/dev/blazelight/p4oc/data/files/ofish/OfishMutationParser.kt +++ b/app/src/main/java/dev/blazelight/p4oc/data/files/ofish/OfishMutationParser.kt @@ -3,20 +3,33 @@ package dev.blazelight.p4oc.data.files.ofish internal object OfishMutationParser { private val STATUS_LINE = Regex("^###\\s+(\\d{3})\\s+(\\S+)(?:\\s+(.*))?$") private val KEY_VALUE = Regex("(\\w+)=([^\\s]+)") + private val UPLOAD_VALUE = Regex("(?:^|\\s)upload=(.*)$") - fun parse(output: String): OfishMutationStatus { - val line = output.lineSequence() - .map { it.trim() } - .filter { it.startsWith("### ") } - .lastOrNull() - ?: return OfishMutationStatus.Malformed("Malformed OFISH mutation output: missing status line") + @Suppress("ReturnCount") + fun parse(output: String, expectedMarker: String): OfishMutationStatus { + val lines = output.lineSequence().toList() + val markerIndex = lines.indexOfFirst { it == expectedMarker } + if (markerIndex == -1) { + return OfishMutationStatus.Malformed("Malformed OFISH mutation output: missing $expectedMarker marker") + } - val match = STATUS_LINE.matchEntire(line) - ?: return OfishMutationStatus.Malformed("Malformed OFISH mutation output: invalid status line") + // A shell command prints its marker before doing any work and exactly one terminal + // status line. Stop at the first valid status in that segment: text produced by the + // assistant before the marker or after the command's status is not command output. + val match = lines.asSequence() + .drop(markerIndex + 1) + .takeWhile { !it.startsWith("#OFISH_") } + .map { it.trimStart() } + .mapNotNull(STATUS_LINE::matchEntire) + .firstOrNull() + ?: return OfishMutationStatus.Malformed("Malformed OFISH mutation output: missing status line") val code = match.groupValues[1].toInt() val status = match.groupValues[2] - val remainder = match.groupValues.getOrNull(3).orEmpty().trim() - val values = KEY_VALUE.findAll(remainder).associate { it.groupValues[1] to it.groupValues[2].trim() } + val remainder = match.groupValues.getOrNull(3).orEmpty() + val values = KEY_VALUE.findAll(remainder).associate { it.groupValues[1] to it.groupValues[2] }.toMutableMap() + // The upload token is a path emitted as the final field. Unlike hashes and reason codes, + // it may contain whitespace and must reach shell quoting byte-for-byte intact. + UPLOAD_VALUE.find(remainder)?.groupValues?.get(1)?.let { values["upload"] = it } return when (code) { 200, 201 -> OfishMutationStatus.Ok( diff --git a/app/src/main/java/dev/blazelight/p4oc/data/files/ofish/OfishSessionFactory.kt b/app/src/main/java/dev/blazelight/p4oc/data/files/ofish/OfishSessionFactory.kt index 41561a4d..878ddd49 100644 --- a/app/src/main/java/dev/blazelight/p4oc/data/files/ofish/OfishSessionFactory.kt +++ b/app/src/main/java/dev/blazelight/p4oc/data/files/ofish/OfishSessionFactory.kt @@ -43,7 +43,7 @@ internal class OfishSessionFactory( .onFailure { error -> AppLog.w( TAG, - "Failed to delete OFISH session ${session.id}: ${error.message}" + "Failed to delete OFISH session: ${error.javaClass.simpleName}" ) } } @@ -71,7 +71,7 @@ internal class OfishSessionFactory( .onSuccess { deleted += 1 } .onFailure { error -> failed += 1 - AppLog.w(TAG, "Failed to sweep stale OFISH session ${session.id}: ${error.message}") + AppLog.w(TAG, "Failed to sweep stale OFISH session: ${error.javaClass.simpleName}") } } } @@ -83,7 +83,7 @@ internal class OfishSessionFactory( failed = failed, ) }.getOrElse { error -> - AppLog.w(TAG, "Failed to list OFISH sessions for sweep: ${error.message}") + AppLog.w(TAG, "Failed to list OFISH sessions for sweep: ${error.javaClass.simpleName}") OfishSweepReport(scanned = 0, staleFound = 0, deleted = 0, failed = 1) } } @@ -105,7 +105,8 @@ internal class OfishSessionFactory( val report = sweepStaleSessions(maxAgeMillis = STALE_SESSION_AGE_MILLIS) AppLog.i( TAG, - "OFISH stale session sweep workspace=$workspaceKey scanned=${report.scanned} stale=${report.staleFound} deleted=${report.deleted} failed=${report.failed}", + "OFISH sweep scanned=${report.scanned} stale=${report.staleFound} " + + "deleted=${report.deleted} failed=${report.failed}", ) } diff --git a/app/src/main/java/dev/blazelight/p4oc/data/files/ofish/OfishWorkspaceClient.kt b/app/src/main/java/dev/blazelight/p4oc/data/files/ofish/OfishWorkspaceClient.kt index c960397a..948c7ee9 100644 --- a/app/src/main/java/dev/blazelight/p4oc/data/files/ofish/OfishWorkspaceClient.kt +++ b/app/src/main/java/dev/blazelight/p4oc/data/files/ofish/OfishWorkspaceClient.kt @@ -17,7 +17,7 @@ internal interface OfishWorkspaceClient { suspend fun executeShellCommand(sessionId: String, request: ShellCommandRequest): MessageWrapperDto - suspend fun listSessionsCurrentWorkspace(limit: Int? = null): List + suspend fun listSessionsCurrentWorkspace(limit: Int?): List suspend fun respondToPermission(id: String, request: PermissionResponseRequest): Boolean } diff --git a/app/src/main/java/dev/blazelight/p4oc/data/remote/dto/AgentDtos.kt b/app/src/main/java/dev/blazelight/p4oc/data/remote/dto/AgentDtos.kt index a7df9487..9782b961 100644 --- a/app/src/main/java/dev/blazelight/p4oc/data/remote/dto/AgentDtos.kt +++ b/app/src/main/java/dev/blazelight/p4oc/data/remote/dto/AgentDtos.kt @@ -22,10 +22,12 @@ data class AgentDto( val color: String? = null, val permission: JsonElement? = null, // Array of PermissionRuleDto from server val model: ModelRefDto? = null, + val variant: String? = null, val prompt: String? = null, val tools: Map? = null, val options: JsonObject? = null, val maxSteps: Int? = null, + val steps: Double? = null, val systemPrompt: String? = null, val isEnabled: Boolean? = null, val isBuiltIn: Boolean? = null diff --git a/app/src/main/java/dev/blazelight/p4oc/data/remote/dto/AuthDtos.kt b/app/src/main/java/dev/blazelight/p4oc/data/remote/dto/AuthDtos.kt index c77387a6..ee2ee295 100644 --- a/app/src/main/java/dev/blazelight/p4oc/data/remote/dto/AuthDtos.kt +++ b/app/src/main/java/dev/blazelight/p4oc/data/remote/dto/AuthDtos.kt @@ -1,6 +1,7 @@ package dev.blazelight.p4oc.data.remote.dto import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject // ============================================================================ // Auth Types @@ -12,13 +13,15 @@ data class OAuthDto( val refresh: String, val access: String, val expires: Long, + val accountId: String? = null, val enterpriseUrl: String? = null ) @Serializable data class ApiAuthDto( val type: String = "api", - val key: String + val key: String, + val metadata: Map? = null, ) @Serializable @@ -34,13 +37,15 @@ data class AuthDto( val refresh: String? = null, val access: String? = null, val expires: Long? = null, + val accountId: String? = null, val enterpriseUrl: String? = null, val key: String? = null, - val token: String? = null + val token: String? = null, + val metadata: JsonObject? = null, ) @Serializable data class OAuthCallbackRequest( - val code: String? = null, - val state: String? = null + val method: Int, + val code: String? = null ) diff --git a/app/src/main/java/dev/blazelight/p4oc/data/remote/dto/CommandDtos.kt b/app/src/main/java/dev/blazelight/p4oc/data/remote/dto/CommandDtos.kt index e4aeff0d..e033730c 100644 --- a/app/src/main/java/dev/blazelight/p4oc/data/remote/dto/CommandDtos.kt +++ b/app/src/main/java/dev/blazelight/p4oc/data/remote/dto/CommandDtos.kt @@ -16,7 +16,9 @@ data class CommandDto( val model: String? = null, val template: JsonElement? = null, // Can be String or Object (MCP commands use {}) val subtask: Boolean? = null, - val mcp: Boolean? = null + val mcp: Boolean? = null, + val hints: List = emptyList(), + val source: String? = null, ) @Serializable diff --git a/app/src/main/java/dev/blazelight/p4oc/data/remote/dto/ConfigDtos.kt b/app/src/main/java/dev/blazelight/p4oc/data/remote/dto/ConfigDtos.kt index 90253c79..aa347ae4 100644 --- a/app/src/main/java/dev/blazelight/p4oc/data/remote/dto/ConfigDtos.kt +++ b/app/src/main/java/dev/blazelight/p4oc/data/remote/dto/ConfigDtos.kt @@ -95,6 +95,7 @@ data class McpConfigDto( val type: String, // "local" | "remote" // Local MCP val command: List? = null, + val cwd: String? = null, val environment: Map? = null, // Remote MCP val url: String? = null, diff --git a/app/src/main/java/dev/blazelight/p4oc/data/remote/dto/EventDtos.kt b/app/src/main/java/dev/blazelight/p4oc/data/remote/dto/EventDtos.kt index 176616fe..4cde366a 100644 --- a/app/src/main/java/dev/blazelight/p4oc/data/remote/dto/EventDtos.kt +++ b/app/src/main/java/dev/blazelight/p4oc/data/remote/dto/EventDtos.kt @@ -10,6 +10,7 @@ import kotlinx.serialization.json.JsonObject @Serializable data class EventDataDto( + val id: String? = null, val type: String, // Some server events carry no `properties` — notably the v2 "sync" mirror // (`{type:"sync", syncEvent:{…}}`) the daemon emits alongside every normal @@ -27,6 +28,8 @@ data class GlobalEventDto( // so kotlinx deserialization accepts those events while workspace routing // still receives explicit non-null directories when the server sends them. val directory: String? = null, + val project: String? = null, + val workspace: String? = null, val payload: EventDataDto ) diff --git a/app/src/main/java/dev/blazelight/p4oc/data/remote/dto/FileDtos.kt b/app/src/main/java/dev/blazelight/p4oc/data/remote/dto/FileDtos.kt index 546bc381..e6066b95 100644 --- a/app/src/main/java/dev/blazelight/p4oc/data/remote/dto/FileDtos.kt +++ b/app/src/main/java/dev/blazelight/p4oc/data/remote/dto/FileDtos.kt @@ -1,6 +1,5 @@ package dev.blazelight.p4oc.data.remote.dto -import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable // ============================================================================ @@ -54,27 +53,6 @@ data class FileStatusDto( val removed: Int = 0 ) -@Serializable -data class SearchResultDto( - val path: String, - val lines: List? = null, - @SerialName("line_number") val lineNumber: Int? = null, - @SerialName("absolute_offset") val absoluteOffset: Int? = null, - val submatches: List? = null -) - -@Serializable -data class SearchLineDto( - val text: String -) - -@Serializable -data class SubmatchDto( - val match: String, - val start: Int, - val end: Int -) - @Serializable data class SymbolDto( val name: String, diff --git a/app/src/main/java/dev/blazelight/p4oc/data/remote/dto/PartDtos.kt b/app/src/main/java/dev/blazelight/p4oc/data/remote/dto/PartDtos.kt index b041c775..aaabefb5 100644 --- a/app/src/main/java/dev/blazelight/p4oc/data/remote/dto/PartDtos.kt +++ b/app/src/main/java/dev/blazelight/p4oc/data/remote/dto/PartDtos.kt @@ -112,8 +112,3 @@ data class PartInputDto( val description: String? = null, val agent: String? = null ) - -@Serializable -data class SetActiveModelRequest( - val model: ModelInput -) diff --git a/app/src/main/java/dev/blazelight/p4oc/data/remote/dto/ProjectDtos.kt b/app/src/main/java/dev/blazelight/p4oc/data/remote/dto/ProjectDtos.kt index 61be6ac5..79533695 100644 --- a/app/src/main/java/dev/blazelight/p4oc/data/remote/dto/ProjectDtos.kt +++ b/app/src/main/java/dev/blazelight/p4oc/data/remote/dto/ProjectDtos.kt @@ -2,6 +2,7 @@ package dev.blazelight.p4oc.data.remote.dto import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject // ============================================================================ // Project Types (aligned with SDK Project type) @@ -13,12 +14,17 @@ data class ProjectDto( val worktree: String, @SerialName("vcsDir") val vcsDir: String? = null, val vcs: String? = null, // "git" or null - val time: ProjectTimeDto + val time: ProjectTimeDto, + val sandboxes: List = emptyList(), + val name: String? = null, + val icon: JsonObject? = null, + val commands: JsonObject? = null, ) @Serializable data class ProjectTimeDto( val created: Long, + val updated: Long? = null, val initialized: Long? = null ) @@ -28,7 +34,8 @@ data class ProjectTimeDto( @Serializable data class VcsInfoDto( - val branch: String? = null + val branch: String? = null, + @SerialName("default_branch") val defaultBranch: String? = null, ) // ============================================================================ @@ -40,5 +47,6 @@ data class PathInfoDto( val state: String, val config: String, val worktree: String, - val directory: String + val directory: String, + val home: String? = null, ) diff --git a/app/src/main/java/dev/blazelight/p4oc/data/remote/dto/ProviderDtos.kt b/app/src/main/java/dev/blazelight/p4oc/data/remote/dto/ProviderDtos.kt index 938a5cc1..5fc1f8f0 100644 --- a/app/src/main/java/dev/blazelight/p4oc/data/remote/dto/ProviderDtos.kt +++ b/app/src/main/java/dev/blazelight/p4oc/data/remote/dto/ProviderDtos.kt @@ -25,6 +25,7 @@ data class ModelDto( @SerialName("providerID") val providerId: String, val api: ModelApiDto? = null, val name: String, + val family: String? = null, val capabilities: ModelCapabilitiesDto? = null, val cost: ModelCostDto? = null, val limit: ModelLimitDto? = null, @@ -33,6 +34,7 @@ data class ModelDto( val variants: JsonObject? = null, val variant: JsonObject? = null, val headers: Map? = null, + @SerialName("release_date") val releaseDate: String? = null, val contextLength: Int? = null, val inputCostPer1k: Double? = null, val outputCostPer1k: Double? = null, @@ -53,6 +55,7 @@ data class ModelCapabilitiesDto( val reasoning: Boolean = false, val attachment: Boolean = false, val toolcall: Boolean = false, + val interleaved: kotlinx.serialization.json.JsonElement? = null, val input: ModalitiesDto? = null, val output: ModalitiesDto? = null ) @@ -70,7 +73,23 @@ data class ModalitiesDto( data class ModelCostDto( val input: Double = 0.0, val output: Double = 0.0, - val cache: CacheCostDto? = null + val cache: CacheCostDto? = null, + val tiers: List? = null, + val experimentalOver200K: JsonObject? = null, +) + +@Serializable +data class ModelCostTierDto( + val input: Double, + val output: Double, + val cache: CacheCostDto, + val tier: ModelCostTierRuleDto, +) + +@Serializable +data class ModelCostTierRuleDto( + val type: String, + val size: Double, ) @Serializable @@ -95,7 +114,8 @@ data class ProvidersResponseDto( @Serializable data class ProviderAuthMethodDto( val type: String, // "oauth" | "api" - val label: String + val label: String, + val prompts: List? = null, ) @Serializable @@ -104,3 +124,8 @@ data class ProviderAuthAuthorizationDto( val method: String, // "auto" | "code" val instructions: String ) + +@Serializable +data class ProviderAuthAuthorizeRequest( + val method: Int +) diff --git a/app/src/main/java/dev/blazelight/p4oc/data/remote/dto/PtyDtos.kt b/app/src/main/java/dev/blazelight/p4oc/data/remote/dto/PtyDtos.kt index 3d7ec871..879867ad 100644 --- a/app/src/main/java/dev/blazelight/p4oc/data/remote/dto/PtyDtos.kt +++ b/app/src/main/java/dev/blazelight/p4oc/data/remote/dto/PtyDtos.kt @@ -14,7 +14,9 @@ data class PtyDto( val args: List, val cwd: String, val status: String, - val pid: Int? = null // Server may return null for pid + // Some deployed servers return null while the process is starting despite the upstream schema. + val pid: Int? = null, + val exitCode: Int? = null, ) @Serializable diff --git a/app/src/main/java/dev/blazelight/p4oc/data/remote/dto/QuestionDtos.kt b/app/src/main/java/dev/blazelight/p4oc/data/remote/dto/QuestionDtos.kt index b3d9b195..85c9f2ea 100644 --- a/app/src/main/java/dev/blazelight/p4oc/data/remote/dto/QuestionDtos.kt +++ b/app/src/main/java/dev/blazelight/p4oc/data/remote/dto/QuestionDtos.kt @@ -15,6 +15,12 @@ data class QuestionRequestDto( val tool: QuestionToolRefDto? = null ) +/** Exact response envelope returned by v2.session.question.list. */ +@Serializable +data class QuestionV2RequestListResponseDto( + val data: List +) + @Serializable data class QuestionToolRefDto( @SerialName("messageID") val messageID: String, @@ -40,3 +46,6 @@ data class QuestionOptionDto( data class QuestionReplyRequest( val answers: List> ) + +typealias QuestionV2Request = QuestionRequestDto +typealias QuestionV2Reply = QuestionReplyRequest diff --git a/app/src/main/java/dev/blazelight/p4oc/data/remote/dto/SessionDtos.kt b/app/src/main/java/dev/blazelight/p4oc/data/remote/dto/SessionDtos.kt index df8978ff..25ee4cec 100644 --- a/app/src/main/java/dev/blazelight/p4oc/data/remote/dto/SessionDtos.kt +++ b/app/src/main/java/dev/blazelight/p4oc/data/remote/dto/SessionDtos.kt @@ -2,6 +2,8 @@ package dev.blazelight.p4oc.data.remote.dto import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject // ============================================================================ // Session Types @@ -17,17 +19,33 @@ data class TimeDto( @Serializable data class SessionDto( val id: String, + val slug: String? = null, @SerialName("projectID") val projectID: String, + @SerialName("workspaceID") val workspaceID: String? = null, val directory: String, + val path: String? = null, @SerialName("parentID") val parentID: String? = null, val title: String, val version: String, val time: TimeDto, val summary: SessionSummaryDto? = null, + val cost: Double? = null, + val tokens: TokenUsageDto? = null, val share: SessionShareDto? = null, + val agent: String? = null, + val model: SessionModelDto? = null, + val metadata: JsonObject? = null, + val permission: JsonElement? = null, val revert: SessionRevertDto? = null ) +@Serializable +data class SessionModelDto( + val id: String, + @SerialName("providerID") val providerID: String, + val variant: String? = null, +) + @Serializable data class SessionSummaryDto( val additions: Int, @@ -45,6 +63,16 @@ data class FileDiffDto( val deletions: Int ) +/** Current response shape for `GET /session/{sessionID}/diff`. */ +@Serializable +data class SnapshotFileDiffDto( + val file: String? = null, + val patch: String? = null, + val additions: Double, + val deletions: Double, + val status: String? = null, +) + @Serializable data class SessionShareDto( val url: String diff --git a/app/src/main/java/dev/blazelight/p4oc/data/remote/mapper/Mappers.kt b/app/src/main/java/dev/blazelight/p4oc/data/remote/mapper/Mappers.kt index 2577414f..39295ac5 100644 --- a/app/src/main/java/dev/blazelight/p4oc/data/remote/mapper/Mappers.kt +++ b/app/src/main/java/dev/blazelight/p4oc/data/remote/mapper/Mappers.kt @@ -690,6 +690,7 @@ class EventMapper constructor( MessageError( name = error.name, message = (error.data?.get("message") as? JsonPrimitive)?.contentOrNull, + providerID = (error.data?.get("providerID") as? JsonPrimitive)?.contentOrNull, responseBody = (error.data?.get("responseBody") as? JsonPrimitive)?.contentOrNull, ) } @@ -711,15 +712,15 @@ class EventMapper constructor( val props = json.decodeFromJsonElement(dto.properties) OpenCodeEvent.PermissionReplied(props.sessionID, props.requestID, props.reply) } - "question.asked" -> { + "question.asked", "question.v2.asked" -> { val questionDto = json.decodeFromJsonElement(dto.properties) OpenCodeEvent.QuestionAsked(mapQuestionRequestDtoToDomain(questionDto)) } - "question.replied" -> { + "question.replied", "question.v2.replied" -> { val props = json.decodeFromJsonElement(dto.properties) OpenCodeEvent.QuestionReplied(props.sessionID, props.requestID, props.answers) } - "question.rejected" -> { + "question.rejected", "question.v2.rejected" -> { val props = json.decodeFromJsonElement(dto.properties) OpenCodeEvent.QuestionRejected(props.sessionID, props.requestID) } @@ -742,6 +743,21 @@ class EventMapper constructor( val props = json.decodeFromJsonElement(dto.properties) OpenCodeEvent.VcsBranchUpdated(props.branch) } + "project.updated" -> { + val props = json.decodeFromJsonElement(dto.properties) + OpenCodeEvent.ProjectUpdated(ProjectMapper.mapToDomain(props.info)) + } + "project.directories.updated" -> { + val props = json.decodeFromJsonElement(dto.properties) + OpenCodeEvent.ProjectDirectoriesUpdated(props.projectID) + } + "models-dev.refreshed" -> OpenCodeEvent.ModelsRefreshed + "catalog.updated" -> OpenCodeEvent.CatalogUpdated + "mcp.tools.changed" -> { + val props = json.decodeFromJsonElement(dto.properties) + OpenCodeEvent.McpToolsChanged(props.server) + } + "global.disposed" -> OpenCodeEvent.GlobalDisposed "session.idle" -> { val props = json.decodeFromJsonElement(dto.properties) OpenCodeEvent.SessionIdle(props.sessionID) @@ -792,7 +808,7 @@ class EventMapper constructor( else -> null } } catch (e: Exception) { - AppLog.e("EventMapper", "Failed to map event type=${dto.type}: ${e.message}", e) + AppLog.e("EventMapper", "Failed to map event (${e::class.simpleName})") null } } @@ -852,6 +868,21 @@ private data class InstallationUpdatedPropertiesDto( val version: String ) +@kotlinx.serialization.Serializable +private data class ProjectUpdatedPropertiesDto( + val info: ProjectDto +) + +@kotlinx.serialization.Serializable +private data class ProjectDirectoriesUpdatedPropertiesDto( + @SerialName("projectID") val projectID: String +) + +@kotlinx.serialization.Serializable +private data class McpToolsChangedPropertiesDto( + val server: String +) + @kotlinx.serialization.Serializable private data class LspClientDiagnosticsPropertiesDto( @SerialName("serverID") val serverID: String, diff --git a/app/src/main/java/dev/blazelight/p4oc/data/session/HydrationEventBuffer.kt b/app/src/main/java/dev/blazelight/p4oc/data/session/HydrationEventBuffer.kt index 591c0fe2..b95ff040 100644 --- a/app/src/main/java/dev/blazelight/p4oc/data/session/HydrationEventBuffer.kt +++ b/app/src/main/java/dev/blazelight/p4oc/data/session/HydrationEventBuffer.kt @@ -22,15 +22,21 @@ class HydrationEventBuffer( RepoState.Hydrating(bufferedEvents = events.size) } - fun replayOver(snapshot: Snapshot, reducer: SessionReducer): Snapshot = snapshotEvents() + fun replayOver(snapshot: Snapshot, reducer: SessionReducer): Snapshot = drain() .fold(snapshot) { current, event -> reducer.reduce(current, event) } + fun drain(): List = synchronized(lock) { + if (events.isEmpty()) return emptyList() + + val drained = events.toList() + events.clear() + drained + } + fun clear() { synchronized(lock) { events.clear() } } - private fun snapshotEvents(): List = synchronized(lock) { events.toList() } - companion object { const val DEFAULT_CAPACITY: Int = 512 } diff --git a/app/src/main/java/dev/blazelight/p4oc/data/session/SessionReducer.kt b/app/src/main/java/dev/blazelight/p4oc/data/session/SessionReducer.kt index 5cbb7b0a..b2df62bb 100644 --- a/app/src/main/java/dev/blazelight/p4oc/data/session/SessionReducer.kt +++ b/app/src/main/java/dev/blazelight/p4oc/data/session/SessionReducer.kt @@ -2,6 +2,7 @@ package dev.blazelight.p4oc.data.session import dev.blazelight.p4oc.data.files.ofish.OfishSessionNames import dev.blazelight.p4oc.domain.model.OpenCodeEvent +import dev.blazelight.p4oc.domain.model.SessionStatus import dev.blazelight.p4oc.domain.session.SessionId import dev.blazelight.p4oc.domain.session.WorkspaceSession import dev.blazelight.p4oc.domain.workspace.Workspace @@ -34,11 +35,21 @@ open class SessionReducer( } is OpenCodeEvent.SessionDeleted -> snapshot.copy( sessions = snapshot.sessions - event.session.id, + statuses = snapshot.statuses - event.session.id, ) + is OpenCodeEvent.SessionStatusChanged -> snapshot.withStatus(event.sessionID, event.status) + is OpenCodeEvent.SessionIdle -> snapshot.withStatus(event.sessionID, SessionStatus.Idle) + is OpenCodeEvent.SessionError -> event.sessionID?.let { sessionId -> + snapshot.withStatus(sessionId, SessionStatus.Idle) + } ?: snapshot else -> snapshot } private fun Snapshot.upsert(session: WorkspaceSession): Snapshot = copy( sessions = sessions + (session.id.value to session), ) + + private fun Snapshot.withStatus(sessionId: String, status: SessionStatus): Snapshot = copy( + statuses = statuses + (sessionId to status), + ) } diff --git a/app/src/main/java/dev/blazelight/p4oc/data/session/SessionRepository.kt b/app/src/main/java/dev/blazelight/p4oc/data/session/SessionRepository.kt index f543c160..3bcda440 100644 --- a/app/src/main/java/dev/blazelight/p4oc/data/session/SessionRepository.kt +++ b/app/src/main/java/dev/blazelight/p4oc/data/session/SessionRepository.kt @@ -21,13 +21,20 @@ interface SessionRepository { fun sessionUiState(sessionId: SessionId): StateFlow + /** + * Keeps the cached message and UI state for [sessionId] alive until the returned + * lease is closed. The final lease release evicts that per-session state. + */ + fun acquireSession(sessionId: SessionId): AutoCloseable + fun clearPermission(sessionId: SessionId, permissionId: String) fun clearPermissionByRequestId(sessionId: SessionId, requestId: String) fun clearQuestion(sessionId: SessionId, requestId: String? = null) - suspend fun loadMessages(sessionId: SessionId, limit: Int? = null) + /** Loads the newest [limit] messages and returns the number supplied by the server. */ + suspend fun loadMessages(sessionId: SessionId, limit: Int): Int fun sendMessageAsync(sessionId: SessionId, request: SendMessageRequest): Deferred> diff --git a/app/src/main/java/dev/blazelight/p4oc/data/session/SessionRepositoryImpl.kt b/app/src/main/java/dev/blazelight/p4oc/data/session/SessionRepositoryImpl.kt index f0222ecc..dbfd8564 100644 --- a/app/src/main/java/dev/blazelight/p4oc/data/session/SessionRepositoryImpl.kt +++ b/app/src/main/java/dev/blazelight/p4oc/data/session/SessionRepositoryImpl.kt @@ -32,11 +32,13 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.cancel import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -45,6 +47,7 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit +import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger import kotlin.coroutines.coroutineContext @@ -65,6 +68,8 @@ class SessionRepositoryImpl( private val reducer = SessionReducer(client.workspace) private val hydrateBuffer = HydrationEventBuffer() + private val hydrationTransitionLock = Any() + private var hydrationGeneration = 0L private val job = SupervisorJob() private val scope = CoroutineScope(job + dispatcher) @@ -79,11 +84,13 @@ class SessionRepositoryImpl( private val messageStates = mutableMapOf>>() private val sessionUiStates = mutableMapOf>() + private val sessionConsumerCounts = mutableMapOf() private val childToParentSessionIds = mutableMapOf() // Question reconciliation dedup state private val detectedQuestionToolCallIds = mutableSetOf() private val recentlyResolvedQuestionIds = mutableMapOf() + private var projectRefreshJob: Job? = null fun peek(): CachedSnapshot? { val cached = lastSuccess ?: return null @@ -135,8 +142,7 @@ class SessionRepositoryImpl( } override suspend fun refresh() { - val snapshot = hydrate(client.listProjects()).snapshot - _state.value = RepoState.Live(snapshot) + hydrate(client.listProjects()) } suspend fun searchSessionsInWorkspace(query: String, directory: String): List { @@ -168,7 +174,7 @@ class SessionRepositoryImpl( ).filterNot { dto -> OfishSessionNames.isOfishTitle(dto.title) } .map { dto -> workspaceSession(SessionMapper.mapToDomain(dto)) } }.onFailure { error -> - AppLog.e(TAG, "Failed to search sessions for ${searchDirectory ?: "global"}: ${error.message}") + AppLog.e(TAG, "Failed to search sessions: ${error.javaClass.simpleName}") } } }.awaitAll() @@ -189,30 +195,57 @@ class SessionRepositoryImpl( return WorkspaceSession(id, client.workspace, session) } + @Suppress("CyclomaticComplexMethod", "LongMethod", "ReturnCount") override fun acceptEvent(event: OpenCodeEvent) { + if (event is OpenCodeEvent.ProjectUpdated || event is OpenCodeEvent.ProjectDirectoriesUpdated) { + projectRefreshJob?.cancel() + projectRefreshJob = scope.launch { + delay(PROJECT_EVENT_REFRESH_DEBOUNCE_MS) + runCatching { refresh() } + .onFailure { error -> + if (error is CancellationException) throw error + AppLog.w(TAG, "Project event refresh failed: ${error.javaClass.simpleName}") + } + } + return + } if (event is OpenCodeEvent.Connected) { - hydrateAfterReconnect() - scope.launch { reconcileObservedPendingPermissions() } + val hydration = hydrateAfterReconnect() + scope.launch { + try { + hydration.await() + reconcilePendingQuestionsForOwnedSessions() + reconcileObservedPendingPermissions() + } catch (e: Exception) { + AppLog.w(TAG, "Error during post-reconnect reconciliation: ${e.javaClass.simpleName}") + } + } return } - _state.value = when (val current = _state.value) { - is RepoState.Hydrating -> if (isSessionEvent(event)) hydrateBuffer.buffer(event).copy(snapshot = current.snapshot) else current - is RepoState.Live -> RepoState.Live(reducer.reduce(current.snapshot, event)) - is RepoState.Stale -> current.copy(snapshot = reducer.reduce(current.snapshot, event)) + synchronized(hydrationTransitionLock) { + _state.value = when (val current = _state.value) { + is RepoState.Hydrating -> if (isSessionEvent(event)) hydrateBuffer.buffer(event).copy(snapshot = current.snapshot) else current + is RepoState.Live -> RepoState.Live(reducer.reduce(current.snapshot, event)) + is RepoState.Stale -> current.copy(snapshot = reducer.reduce(current.snapshot, event)) + } } when (event) { is OpenCodeEvent.SessionCreated -> { - event.session.parentID?.let { parentId -> - synchronized(childToParentSessionIds) { childToParentSessionIds[event.session.id] = parentId } - } + updateSessionOwnership(event.session) } is OpenCodeEvent.SessionDeleted -> { - synchronized(childToParentSessionIds) { childToParentSessionIds.remove(event.session.id) } - sessionUiStates.remove(event.session.id) + removeSessionOwnership(event.session.id) + synchronized(sessionUiStates) { sessionUiStates.remove(event.session.id) } + synchronized(messageStates) { + messageStates.remove(event.session.id)?.value = emptyList() + } + } + is OpenCodeEvent.SessionUpdated -> { + updateSessionOwnership(event.session) + updateSession(event.session.id) { it.copy(session = event.session) } } - is OpenCodeEvent.SessionUpdated -> updateSession(event.session.id) { it.copy(session = event.session) } is OpenCodeEvent.SessionStatusChanged -> { updateSession(event.sessionID) { state -> state.copy( @@ -261,10 +294,11 @@ class SessionRepositoryImpl( } is OpenCodeEvent.QuestionAsked -> { updateOwnedSession(event.request.sessionID) { state -> - if (state.pendingQuestion == null) { - state.copy(pendingQuestion = event.request) - } else { - state.copy(queuedQuestions = state.queuedQuestions + event.request) + when { + state.pendingQuestion?.id == event.request.id || + state.queuedQuestions.any { it.id == event.request.id } -> state + state.pendingQuestion == null -> state.copy(pendingQuestion = event.request) + else -> state.copy(queuedQuestions = state.queuedQuestions + event.request) } } } @@ -280,8 +314,14 @@ class SessionRepositoryImpl( } } - private suspend fun fetchPendingQuestions(): List = - questionFetcher?.invoke() ?: (client as? WorkspaceClient)?.listPendingQuestions() ?: emptyList() + private suspend fun fetchPendingQuestions(sessionId: String): List = + questionFetcher?.invoke()?.filter { it.sessionID == sessionId } + ?: client.listSessionQuestions(sessionId) + + private suspend fun reconcilePendingQuestionsForOwnedSessions() { + val sessionIds = synchronized(sessionUiStates) { sessionUiStates.keys.toList() } + sessionIds.forEach { reconcilePendingQuestions(it) } + } /** * Reconcile pending questions from the server. @@ -290,11 +330,11 @@ class SessionRepositoryImpl( * pendingQuestion on owned sessions that don't already have one. * Skips questions that were recently resolved (anti-resurrection). */ - private suspend fun reconcilePendingQuestions() { + private suspend fun reconcilePendingQuestions(sessionId: String) { AppLog.d(TAG, "reconcilePendingQuestions: fetching pending questions") - val questionsToCheck = runCatching { fetchPendingQuestions() } + val questionsToCheck = runCatching { fetchPendingQuestions(sessionId) } .getOrElse { error -> - AppLog.w(TAG, "Failed to fetch pending questions: ${error.message}") + AppLog.w(TAG, "Failed to fetch pending questions: ${error.javaClass.simpleName}") return } AppLog.d(TAG, "reconcilePendingQuestions: fetched ${questionsToCheck.size} pending question(s)") @@ -327,22 +367,15 @@ class SessionRepositoryImpl( } } - private fun hydrateAfterReconnect() { - synchronized(this) { - if (inFlight != null) return - _state.value = RepoState.Hydrating(snapshot = state.value.snapshot, bufferedEvents = hydrateBuffer.size) - inFlight = scope.async { - runCatching { hydrate(client.listProjects()) } - } - } - // Trigger question reconciliation after hydration (don't block event path) - scope.launch { - try { - inFlight?.await() - reconcilePendingQuestions() - } catch (e: Exception) { - AppLog.w(TAG, "Error during post-reconnect question reconciliation: ${e.message}") + private fun hydrateAfterReconnect(): Deferred> { + return synchronized(this) { + inFlight?.let { return@synchronized it } + synchronized(hydrationTransitionLock) { + _state.value = RepoState.Hydrating(snapshot = state.value.snapshot, bufferedEvents = hydrateBuffer.size) } + scope.async { + runCatching { hydrate(client.listProjects()) } + }.also { inFlight = it } } } @@ -354,6 +387,35 @@ class SessionRepositoryImpl( sessionId.value ).asStateFlow() + override fun acquireSession(sessionId: SessionId): AutoCloseable { + synchronized(sessionConsumerCounts) { + sessionConsumerCounts[sessionId.value] = sessionConsumerCounts.getOrDefault(sessionId.value, 0) + 1 + } + val released = AtomicBoolean(false) + return AutoCloseable { + if (released.compareAndSet(false, true)) releaseSession(sessionId.value) + } + } + + private fun releaseSession(sessionId: String) { + synchronized(sessionConsumerCounts) { + val remaining = (sessionConsumerCounts[sessionId] ?: return) - 1 + if (remaining > 0) { + sessionConsumerCounts[sessionId] = remaining + return + } else { + sessionConsumerCounts.remove(sessionId) + } + + synchronized(messageStates) { + messageStates.remove(sessionId)?.value = emptyList() + } + synchronized(sessionUiStates) { + sessionUiStates.remove(sessionId)?.value = SessionUiState() + } + } + } + override fun clearPermission(sessionId: SessionId, permissionId: String) { updateSession(sessionId.value) { state -> state.copy( @@ -427,7 +489,8 @@ class SessionRepositoryImpl( } } - override suspend fun loadMessages(sessionId: SessionId, limit: Int?) { + override suspend fun loadMessages(sessionId: SessionId, limit: Int): Int { + require(limit > 0) { "Message history limit must be positive" } val workspaceClient = client as? WorkspaceClient ?: error("Message loading requires WorkspaceClient") val mapper = messageMapper ?: error("Message loading requires MessageMapper") @@ -441,9 +504,10 @@ class SessionRepositoryImpl( mwp.parts.any { it is Part.Tool && it.isQuestionTool() && it.state is ToolState.Running } } if (hasRunningQuestion) { - reconcilePendingQuestions() + reconcilePendingQuestions(sessionId.value) } reconcilePendingPermissions(sessionId.value) + return messages.size } override fun sendMessageAsync(sessionId: SessionId, request: SendMessageRequest): Deferred> = scope.async { @@ -467,10 +531,12 @@ class SessionRepositoryImpl( } override fun close() { + projectRefreshJob?.cancel() invalidate() job.cancel("SessionRepository closed") synchronized(messageStates) { messageStates.clear() } synchronized(sessionUiStates) { sessionUiStates.clear() } + synchronized(sessionConsumerCounts) { sessionConsumerCounts.clear() } synchronized(childToParentSessionIds) { childToParentSessionIds.clear() } synchronized(detectedQuestionToolCallIds) { detectedQuestionToolCallIds.clear() } synchronized(recentlyResolvedQuestionIds) { recentlyResolvedQuestionIds.clear() } @@ -523,8 +589,16 @@ class SessionRepositoryImpl( refresh() } + @Suppress( + "CyclomaticComplexMethod", + "LongMethod", // Atomic generation and buffer ownership branches must remain co-located. + ) private suspend fun hydrate(seedProjects: List): CachedSnapshot { - _state.value = RepoState.Hydrating(snapshot = state.value.snapshot, bufferedEvents = hydrateBuffer.size) + val generation = synchronized(hydrationTransitionLock) { + val nextGeneration = ++hydrationGeneration + _state.value = RepoState.Hydrating(snapshot = state.value.snapshot, bufferedEvents = hydrateBuffer.size) + nextGeneration + } val workspaceKey = client.workspace.key.toString() try { @@ -534,14 +608,17 @@ class SessionRepositoryImpl( val completedSteps = AtomicInteger(0) fun updateHydrationState(currentStep: String? = null, completed: Int = completedSteps.get()) { - val current = _state.value as? RepoState.Hydrating - _state.value = RepoState.Hydrating( - snapshot = state.value.snapshot, - bufferedEvents = current?.bufferedEvents ?: hydrateBuffer.size, - completedSteps = completed, - totalSteps = totalSteps, - currentStep = currentStep, - ) + synchronized(hydrationTransitionLock) { + if (generation != hydrationGeneration) return + val current = _state.value as? RepoState.Hydrating + _state.value = RepoState.Hydrating( + snapshot = state.value.snapshot, + bufferedEvents = current?.bufferedEvents ?: hydrateBuffer.size, + completedSteps = completed, + totalSteps = totalSteps, + currentStep = currentStep, + ) + } } suspend fun trackedStep(label: String, block: suspend () -> T): T { @@ -581,7 +658,7 @@ class SessionRepositoryImpl( val globalSessions = runCatching { globalDeferred.await() } .getOrElse { error -> - AppLog.e(TAG, "Failed to load global sessions: ${error.message}") + AppLog.e(TAG, "Failed to load global sessions: ${error.javaClass.simpleName}") emptyList() } .filterNot { dto -> OfishSessionNames.isOfishTitle(dto.title) } @@ -590,7 +667,7 @@ class SessionRepositoryImpl( val projectSessions = projectDeferreds.awaitAll().flatMap { (result, project) -> runCatching { result } .getOrElse { error -> - AppLog.e(TAG, "Failed to load sessions for ${project.worktree}: ${error.message}") + AppLog.e(TAG, "Failed to load project sessions: ${error.javaClass.simpleName}") emptyList() } .filterNot { dto -> OfishSessionNames.isOfishTitle(dto.title) } @@ -619,7 +696,7 @@ class SessionRepositoryImpl( acc[sessionId] = SessionMapper.mapStatusToDomain(dto) } }.onFailure { error -> - AppLog.e(TAG, "Failed to load session statuses: ${error.message}") + AppLog.e(TAG, "Failed to load session statuses: ${error.javaClass.simpleName}") } acc } @@ -635,20 +712,47 @@ class SessionRepositoryImpl( projects = projects, statuses = statuses, ) - val liveSnapshot = hydrateBuffer.replayOver(hydrated, reducer) - hydrateBuffer.clear() - val cached = CachedSnapshot( - snapshot = liveSnapshot, - fetchedAtMs = nowMs(), - workspaceKey = workspaceKey, - ) - lastSuccess = cached - _state.value = RepoState.Live(liveSnapshot) - return cached + var liveSnapshot = hydrated + var ownershipSeeded = false + while (true) { + val drainedEvents = synchronized(hydrationTransitionLock) { + if (generation != hydrationGeneration) { + return CachedSnapshot( + snapshot = liveSnapshot, + fetchedAtMs = nowMs(), + workspaceKey = workspaceKey, + ) + } + if (!ownershipSeeded) { + replaceSessionOwnership(hydrated.sessions.values) + ownershipSeeded = true + } + val events = hydrateBuffer.drain() + if (events.isEmpty()) { + val cached = CachedSnapshot( + snapshot = liveSnapshot, + fetchedAtMs = nowMs(), + workspaceKey = workspaceKey, + ) + lastSuccess = cached + _state.value = RepoState.Live(liveSnapshot) + return cached + } + applySessionOwnershipEvents(events) + events + } + liveSnapshot = drainedEvents.fold(liveSnapshot) { snapshot, event -> + reducer.reduce(snapshot, event) + } + } } catch (e: CancellationException) { throw e } catch (e: Exception) { - _state.value = RepoState.Stale(state.value.snapshot, reason = e.message) + synchronized(hydrationTransitionLock) { + if (generation == hydrationGeneration) { + _state.value = RepoState.Stale(state.value.snapshot, reason = e.message) + } + } throw e } finally { synchronized(this) { @@ -692,12 +796,54 @@ class SessionRepositoryImpl( updateSession(ownerSessionId, transform) } + private fun replaceSessionOwnership(sessions: Collection) { + synchronized(childToParentSessionIds) { + childToParentSessionIds.clear() + sessions.forEach { workspaceSession -> + workspaceSession.session.parentID?.let { parentId -> + childToParentSessionIds[workspaceSession.id.value] = parentId + } + } + } + } + + private fun updateSessionOwnership(session: Session) { + synchronized(childToParentSessionIds) { + val parentId = session.parentID + if (parentId == null) { + childToParentSessionIds.remove(session.id) + } else { + childToParentSessionIds[session.id] = parentId + } + } + } + + private fun removeSessionOwnership(sessionId: String) { + synchronized(childToParentSessionIds) { + childToParentSessionIds.entries.removeAll { (childId, parentId) -> + childId == sessionId || parentId == sessionId + } + } + } + + private fun applySessionOwnershipEvents(events: List) { + events.forEach { event -> + when (event) { + is OpenCodeEvent.SessionCreated -> updateSessionOwnership(event.session) + is OpenCodeEvent.SessionUpdated -> updateSessionOwnership(event.session) + is OpenCodeEvent.SessionDeleted -> removeSessionOwnership(event.session.id) + else -> Unit + } + } + } + private suspend fun reconcileObservedPendingPermissions() { val sessionIds = synchronized(sessionUiStates) { sessionUiStates.keys.toList() } sessionIds.forEach { sessionId -> reconcilePendingPermissions(sessionId) } } private suspend fun reconcilePendingPermissions(sessionId: String) { + val pendingBeforeReconciliation = sessionUiStateFor(sessionId).value.pendingPermissionsByCallId val legacyPermissions = runCatching { client.listPermissions() .filter { permission -> permission.sessionID == sessionId } @@ -713,11 +859,20 @@ class SessionRepositoryImpl( } updateSession(sessionId) { state -> val recovered = permissions.associateBy { permission -> permission.pendingPermissionKey() } - state.copy(pendingPermissionsByCallId = recovered) + val concurrentlyRemovedKeys = pendingBeforeReconciliation.keys - state.pendingPermissionsByCallId.keys + val concurrentlyArrived = state.pendingPermissionsByCallId.filter { (key, permission) -> + pendingBeforeReconciliation[key] != permission + } + state.copy( + pendingPermissionsByCallId = (recovered - concurrentlyRemovedKeys) + concurrentlyArrived + ) } } - private fun mergeLoadedMessages(sessionId: String, loaded: List) { + private fun mergeLoadedMessages( + sessionId: String, + loaded: List, + ) { val state = messageState(sessionId) state.update { current -> val currentById = current.associateBy { it.message.id } @@ -726,7 +881,12 @@ class SessionRepositoryImpl( if (currentMessage == null) { loadedMessage } else { - loadedMessage.copy(parts = mergeParts(loadedMessage.parts, currentMessage.parts)) + // Existing state may contain a newer SSE update than the bounded REST + // snapshot. Keep it authoritative while merging older history around it. + MessageWithParts( + currentMessage.message, + mergeParts(loadedMessage.parts, currentMessage.parts), + ) } }.let { mergedLoaded -> val loadedIds = mergedLoaded.map { it.message.id }.toSet() @@ -767,9 +927,12 @@ class SessionRepositoryImpl( if (isNewDetection) { scope.launch { try { - reconcilePendingQuestions() + reconcilePendingQuestions(part.sessionID) } catch (e: Exception) { - AppLog.w(TAG, "Error reconciling questions on tool detection: ${e.message}") + AppLog.w( + TAG, + "Question reconciliation failed: ${e.javaClass.simpleName}", + ) } } } @@ -791,7 +954,7 @@ class SessionRepositoryImpl( val partIndex = existingMessage.parts.indexOfFirst { it.id == part.id } val updatedParts = if (partIndex >= 0) { existingMessage.parts.toMutableList().apply { - this[partIndex] = applyDelta(this[partIndex], part, delta) + this[partIndex] = mergePartSnapshot(this[partIndex], part, delta) } } else { existingMessage.parts + part @@ -816,9 +979,11 @@ class SessionRepositoryImpl( } } - private fun findSessionIdForPart(messageId: String, partId: String): String? = messageStates.entries.firstOrNull { (_, flow) -> - flow.value.any { message -> message.message.id == messageId && message.parts.any { it.id == partId } } - }?.key + private fun findSessionIdForPart(messageId: String, partId: String): String? = synchronized(messageStates) { + messageStates.entries.firstOrNull { (_, flow) -> + flow.value.any { message -> message.message.id == messageId && message.parts.any { it.id == partId } } + }?.key + } private fun appendDeltaToPart(part: Part, field: String, delta: String): Part = when (part) { is Part.Text -> if (field == "text") part.copy(text = part.text + delta, isStreaming = true) else part @@ -842,13 +1007,20 @@ class SessionRepositoryImpl( } } - private fun applyDelta(existing: Part, incoming: Part, delta: String?): Part = + private fun mergePartSnapshot(existing: Part, incoming: Part, delta: String?): Part = when { - delta != null && incoming is Part.Text && existing is Part.Text -> { - incoming.copy(text = existing.text + delta, isStreaming = true) + incoming is Part.Text && existing is Part.Text -> { + incoming.copy( + isStreaming = incoming.isStreaming || delta != null, + time = incoming.time ?: existing.time, + metadata = incoming.metadata ?: existing.metadata, + ) } - delta != null && incoming is Part.Reasoning && existing is Part.Reasoning -> { - incoming.copy(text = existing.text + delta) + incoming is Part.Reasoning && existing is Part.Reasoning -> { + incoming.copy( + time = incoming.time ?: existing.time, + metadata = incoming.metadata ?: existing.metadata, + ) } else -> incoming } @@ -894,10 +1066,12 @@ class SessionRepositoryImpl( } } - private fun Permission.pendingPermissionKey(): String = callID ?: "permission:$id" + private fun Permission.pendingPermissionKey(): String = + callID?.takeIf { it.isNotBlank() } ?: "permission:$id" private companion object { const val FRESHNESS_MS = 30_000L + const val PROJECT_EVENT_REFRESH_DEBOUNCE_MS = 150L const val MAX_CONCURRENT = 10 const val SEARCH_LIMIT = 100 const val SESSION_HISTORY_LIMIT = Int.MAX_VALUE diff --git a/app/src/main/java/dev/blazelight/p4oc/data/session/SessionRepositoryProvider.kt b/app/src/main/java/dev/blazelight/p4oc/data/session/SessionRepositoryProvider.kt index 7f5065f7..ccf1429a 100644 --- a/app/src/main/java/dev/blazelight/p4oc/data/session/SessionRepositoryProvider.kt +++ b/app/src/main/java/dev/blazelight/p4oc/data/session/SessionRepositoryProvider.kt @@ -1,6 +1,6 @@ package dev.blazelight.p4oc.data.session -import dev.blazelight.p4oc.core.network.ConnectionManager +import dev.blazelight.p4oc.core.network.ServerConnectionRegistry import dev.blazelight.p4oc.data.remote.mapper.MessageMapper import dev.blazelight.p4oc.data.server.ActiveServerApiProvider import dev.blazelight.p4oc.data.workspace.WorkspaceClient @@ -17,7 +17,7 @@ import kotlinx.coroutines.launch class SessionRepositoryProvider( private val activeServerApiProvider: ActiveServerApiProvider, private val messageMapper: MessageMapper, - private val connectionManager: ConnectionManager, + private val serverConnectionRegistry: ServerConnectionRegistry, dispatcher: CoroutineDispatcher = Dispatchers.Default, ) { data class Lease( @@ -44,7 +44,12 @@ class SessionRepositoryProvider( fun acquire(workspace: Workspace, generation: ServerGeneration): Lease = synchronized(this) { val key = workspace.toProviderKey(generation) val entry = entries.getOrPut(key) { - val workspaceClient = WorkspaceClient(workspace, generation, activeServerApiProvider) + val workspaceClient = WorkspaceClient( + workspace = workspace, + generation = generation, + apiProvider = activeServerApiProvider, + connectionState = serverConnectionRegistry.connectionState(workspace.server, generation), + ) val repository = SessionRepositoryImpl(workspaceClient, messageMapper) Entry( workspaceClient = workspaceClient, @@ -75,9 +80,8 @@ class SessionRepositoryProvider( generation: ServerGeneration, repository: SessionRepositoryImpl, ): Job = scope.launch { - connectionManager.scopedEvents.collect { scopedEvent -> - if (scopedEvent.serverRef == workspace.server && - scopedEvent.generation == generation && + serverConnectionRegistry.events(workspace.server).collect { scopedEvent -> + if (scopedEvent.generation == generation && scopedEvent.workspaceKey == workspace.key ) { repository.acceptEvent(scopedEvent.event) diff --git a/app/src/main/java/dev/blazelight/p4oc/data/workspace/SessionWorkspaceClient.kt b/app/src/main/java/dev/blazelight/p4oc/data/workspace/SessionWorkspaceClient.kt index 6e004de3..b4960ca1 100644 --- a/app/src/main/java/dev/blazelight/p4oc/data/workspace/SessionWorkspaceClient.kt +++ b/app/src/main/java/dev/blazelight/p4oc/data/workspace/SessionWorkspaceClient.kt @@ -4,6 +4,7 @@ import dev.blazelight.p4oc.data.remote.dto.CreateSessionRequest import dev.blazelight.p4oc.data.remote.dto.PermissionDto import dev.blazelight.p4oc.data.remote.dto.PermissionV2RequestDto import dev.blazelight.p4oc.data.remote.dto.ProjectDto +import dev.blazelight.p4oc.data.remote.dto.QuestionRequestDto import dev.blazelight.p4oc.data.remote.dto.SendMessageRequest import dev.blazelight.p4oc.data.remote.dto.SessionDto import dev.blazelight.p4oc.data.remote.dto.SessionStatusDto @@ -46,5 +47,7 @@ interface SessionWorkspaceClient { suspend fun listPermissions(): List = emptyList() + suspend fun listSessionQuestions(sessionId: String): List = emptyList() + suspend fun abortSession(id: String): Boolean } diff --git a/app/src/main/java/dev/blazelight/p4oc/data/workspace/WorkspaceClient.kt b/app/src/main/java/dev/blazelight/p4oc/data/workspace/WorkspaceClient.kt index fe4fcb27..3f1c444b 100644 --- a/app/src/main/java/dev/blazelight/p4oc/data/workspace/WorkspaceClient.kt +++ b/app/src/main/java/dev/blazelight/p4oc/data/workspace/WorkspaceClient.kt @@ -1,26 +1,38 @@ +@file:Suppress("ImportOrdering") + package dev.blazelight.p4oc.data.workspace +import dev.blazelight.p4oc.core.network.ConnectionState import dev.blazelight.p4oc.core.network.OpenCodeApi +import dev.blazelight.p4oc.data.remote.dto.AddMcpServerRequest +import dev.blazelight.p4oc.data.remote.dto.AgentDto import dev.blazelight.p4oc.data.remote.dto.CommandDto +import dev.blazelight.p4oc.data.remote.dto.ConfigDto import dev.blazelight.p4oc.data.remote.dto.CreateSessionRequest import dev.blazelight.p4oc.data.remote.dto.ExecuteCommandRequest import dev.blazelight.p4oc.data.remote.dto.FileContentDto -import dev.blazelight.p4oc.data.remote.dto.FileDiffDto import dev.blazelight.p4oc.data.remote.dto.FileNodeDto import dev.blazelight.p4oc.data.remote.dto.FileStatusDto import dev.blazelight.p4oc.data.remote.dto.ForkSessionRequest import dev.blazelight.p4oc.data.remote.dto.InitSessionRequest +import dev.blazelight.p4oc.data.remote.dto.McpStatusDto import dev.blazelight.p4oc.data.remote.dto.MessageWrapperDto +import dev.blazelight.p4oc.data.remote.dto.OAuthCallbackRequest import dev.blazelight.p4oc.data.remote.dto.PermissionDto import dev.blazelight.p4oc.data.remote.dto.PermissionResponseRequest import dev.blazelight.p4oc.data.remote.dto.PermissionV2RequestDto import dev.blazelight.p4oc.data.remote.dto.ProjectDto +import dev.blazelight.p4oc.data.remote.dto.ProviderAuthAuthorizationDto +import dev.blazelight.p4oc.data.remote.dto.ProviderAuthAuthorizeRequest +import dev.blazelight.p4oc.data.remote.dto.ProviderAuthMethodDto +import dev.blazelight.p4oc.data.remote.dto.ProvidersResponseDto import dev.blazelight.p4oc.data.remote.dto.QuestionReplyRequest import dev.blazelight.p4oc.data.remote.dto.QuestionRequestDto import dev.blazelight.p4oc.data.remote.dto.RevertSessionRequest import dev.blazelight.p4oc.data.remote.dto.SendMessageRequest import dev.blazelight.p4oc.data.remote.dto.SessionDto import dev.blazelight.p4oc.data.remote.dto.SessionStatusDto +import dev.blazelight.p4oc.data.remote.dto.SnapshotFileDiffDto import dev.blazelight.p4oc.data.remote.dto.ShellCommandRequest import dev.blazelight.p4oc.data.remote.dto.SymbolDto import dev.blazelight.p4oc.data.remote.dto.TodoDto @@ -29,6 +41,9 @@ import dev.blazelight.p4oc.data.remote.dto.VcsInfoDto import dev.blazelight.p4oc.data.server.ActiveServerApiProvider import dev.blazelight.p4oc.domain.server.ServerGeneration import dev.blazelight.p4oc.domain.workspace.Workspace +import kotlinx.coroutines.flow.StateFlow +import kotlinx.serialization.SerializationException + import retrofit2.HttpException import java.io.IOException @@ -36,12 +51,17 @@ class WorkspaceClient( override val workspace: Workspace, val generation: ServerGeneration, private val apiProvider: ActiveServerApiProvider, + val connectionState: StateFlow, ) : SessionWorkspaceClient { private val api: OpenCodeApi get() = apiProvider.apiFor(workspace.server, generation) private val directory: String? = workspace.directory - override suspend fun listProjects(): List = api.listProjects() + override suspend fun listProjects(): List = api.listProjects(directory = null, workspace = null) + + /** Probes a directory returned by [listProjects] using that project as API scope. */ + suspend fun listProjectFiles(projectDirectory: String, path: String = "."): List = + api.listFiles(path, projectDirectory, workspace = null) override suspend fun listSessions( directory: String?, @@ -50,64 +70,103 @@ class WorkspaceClient( start: Long?, search: String?, limit: Int?, - ): List = api.listSessions(directory, scope, roots, start, search, limit) + ): List = api.listSessions( + directory = directory, + workspace = null, + scope = scope, + path = null, + roots = roots, + start = start, + search = search, + limit = limit, + ) override suspend fun createSession(request: CreateSessionRequest): SessionDto = - api.createSession(directory = directory, request = request) + api.createSession(directory = directory, workspace = null, request = request) + + override suspend fun getSession(id: String): SessionDto = api.getSession(id, directory, workspace = null) + + suspend fun getVcsInfo(): VcsInfoDto = api.getVcsInfo(directory, workspace = null) + + suspend fun getAgents(): List = api.getAgents(directory, workspace = null) + + suspend fun getProviders(): ProvidersResponseDto = api.getProviders(directory, workspace = null) + + suspend fun getMcpStatus(): Map = api.getMcpStatus(directory, workspace = null) + + suspend fun addMcpServer(request: AddMcpServerRequest): Map = + api.addMcpServer(request, directory = directory, workspace = null) + + suspend fun getConfig(): ConfigDto = api.getConfig(directory, workspace = null) + + suspend fun updateConfig(config: ConfigDto): ConfigDto = api.updateConfig(config, directory, workspace = null) + + /** Persists the workspace's default model without replacing unrelated configuration. */ + suspend fun updateCurrentModel(model: String): ConfigDto { + val currentConfig = api.getConfig(directory, workspace = null) + return api.updateConfig(currentConfig.copy(model = model), directory, workspace = null) + } + + suspend fun getProviderAuthMethods(): Map> = + api.getProviderAuthMethods(directory, workspace = null) - override suspend fun getSession(id: String): SessionDto = api.getSession(id, directory) + suspend fun authorizeProvider( + providerId: String, + request: ProviderAuthAuthorizeRequest, + ): ProviderAuthAuthorizationDto = + api.authorizeProvider(providerId, request, directory, workspace = null) - suspend fun getVcsInfo(): VcsInfoDto = api.getVcsInfo(directory) + suspend fun completeProviderOAuth(providerId: String, request: OAuthCallbackRequest): Boolean = + api.oauthCallback(providerId, request, directory, workspace = null) - override suspend fun deleteSession(id: String): Boolean = api.deleteSession(id, directory) + override suspend fun deleteSession(id: String): Boolean = api.deleteSession(id, directory, workspace = null) override suspend fun updateSession(id: String, request: UpdateSessionRequest): SessionDto = - api.updateSession(id, request, directory) + api.updateSession(id, request, directory, workspace = null) - override suspend fun getSessionStatuses(directory: String?): Map = api.getSessionStatuses( - directory - ) + override suspend fun getSessionStatuses(directory: String?): Map = + api.getSessionStatuses(directory, workspace = null) override suspend fun abortSession(id: String): Boolean { - val response = api.abortSession(id, directory) + val response = api.abortSession(id, directory, workspace = null) if (response.isSuccessful) return true throw IOException("Unable to stop run (${response.code()})") } - suspend fun getSessionTodos(id: String): List = api.getSessionTodos(id, directory) + suspend fun getSessionTodos(id: String): List = api.getSessionTodos(id, directory, workspace = null) suspend fun forkSession(id: String, request: ForkSessionRequest): SessionDto = - api.forkSession(id, request, directory) + api.forkSession(id, request, directory, workspace = null) suspend fun initSession(id: String, request: InitSessionRequest): Boolean = - api.initSession(id, request, directory) + api.initSession(id, request, directory, workspace = null) - override suspend fun shareSession(id: String): SessionDto = api.shareSession(id, directory) + override suspend fun shareSession(id: String): SessionDto = api.shareSession(id, directory, workspace = null) - override suspend fun unshareSession(id: String): SessionDto = api.unshareSession(id, directory) + override suspend fun unshareSession(id: String): SessionDto = api.unshareSession(id, directory, workspace = null) - override suspend fun summarizeSession(id: String): Boolean = api.summarizeSession(id, directory) + override suspend fun summarizeSession(id: String): Boolean = api.summarizeSession(id, directory, workspace = null) suspend fun revertSession(id: String, request: RevertSessionRequest): SessionDto = - api.revertSession(id, request, directory) + api.revertSession(id, request, directory, workspace = null) - suspend fun unrevertSession(id: String): SessionDto = api.unrevertSession(id, directory) + suspend fun unrevertSession(id: String): SessionDto = api.unrevertSession(id, directory, workspace = null) - suspend fun getSessionDiff(id: String, messageId: String? = null): List = - api.getSessionDiff(id, messageId, directory) + suspend fun getSessionDiff(id: String, messageId: String? = null): List = + api.getSessionDiff(id, messageId, directory, workspace = null) suspend fun getMessages(sessionId: String, limit: Int? = null): List = - api.getMessages(sessionId, limit, directory) + api.getMessages(sessionId, limit, before = null, directory = directory, workspace = null) override suspend fun sendMessageAsync(sessionId: String, request: SendMessageRequest) { - api.sendMessageAsync(sessionId, request, directory) + api.sendMessageAsync(sessionId, request, directory, workspace = null) } override suspend fun listSessionPermissionsV2(sessionId: String): List = api.listSessionPermissionsV2(sessionId).data - override suspend fun listPermissions(): List = api.listPermissions(directory) + override suspend fun listPermissions(): List = api.listPermissions(directory, workspace = null) suspend fun respondToPermission( sessionId: String, @@ -123,30 +182,85 @@ class WorkspaceClient( } suspend fun respondToPermissionLegacy(requestId: String, request: PermissionResponseRequest): Boolean = - api.respondToPermission(requestId, request, directory) + api.respondToPermission(requestId, request, directory, workspace = null) + + suspend fun respondToQuestion( + sessionId: String, + requestId: String, + request: QuestionReplyRequest, + ): Boolean { + try { + val legacy = api.respondToQuestion(requestId, request, directory, workspace = null) + if (legacy.isUsableQuestionResponse()) return legacy.body() == true + if (!legacy.isUnavailableQuestionEndpoint()) throw HttpException(legacy) + } catch (_: SerializationException) { + // Matches the bounded HTML-route endpoint-unavailable behavior used by question endpoints. + } + + return api.respondToQuestionV2(sessionId, requestId, request).requireUsableV2Response() + } + + suspend fun rejectQuestion(sessionId: String, requestId: String): Boolean { + try { + val legacy = api.rejectQuestion(requestId, directory, workspace = null) + if (legacy.isUsableQuestionResponse()) return legacy.body() == true + if (!legacy.isUnavailableQuestionEndpoint()) throw HttpException(legacy) + } catch (_: SerializationException) { + // Matches the bounded HTML-route endpoint-unavailable behavior used by question endpoints. + } - suspend fun respondToQuestion(requestId: String, request: QuestionReplyRequest): Boolean = - api.respondToQuestion(requestId, request, directory) + return api.rejectQuestionV2(sessionId, requestId).requireUsableV2Response() + } - suspend fun rejectQuestion(requestId: String): Boolean = - api.rejectQuestion(requestId, directory) + override suspend fun listSessionQuestions(sessionId: String): List { + try { + val legacy = api.listPendingQuestions(directory, workspace = null) + if (legacy.isUsableQuestionResponse()) { + return legacy.body().orEmpty().filter { it.sessionID == sessionId } + } + if (!legacy.isUnavailableQuestionEndpoint()) throw HttpException(legacy) + } catch (_: SerializationException) { + // Matches the bounded HTML-route endpoint-unavailable behavior used by question endpoints. + } + + val response = api.listSessionQuestionsV2(sessionId) + if (!response.isUsableV2Response()) throw HttpException(response) + return response.body()?.data.orEmpty() + } suspend fun listPendingQuestions(): List = - api.listPendingQuestions(directory) + api.listPendingQuestions(directory, workspace = null).let { response -> + if (!response.isUsableQuestionResponse()) throw HttpException(response) + response.body().orEmpty() + } + + private fun retrofit2.Response<*>.isUsableV2Response(): Boolean = + isSuccessful && !headers()["Content-Type"].orEmpty().startsWith("text/html") + + private fun retrofit2.Response<*>.isUsableQuestionResponse(): Boolean = + isSuccessful && !headers()["Content-Type"].orEmpty().startsWith("text/html") + + private fun retrofit2.Response<*>.isUnavailableQuestionEndpoint(): Boolean = + code() == 404 || headers()["Content-Type"].orEmpty().startsWith("text/html") + + private fun retrofit2.Response.requireUsableV2Response(): Boolean { + if (!isUsableV2Response()) throw HttpException(this) + return true + } - suspend fun listCommands(): List = api.listCommands(directory) + suspend fun listCommands(): List = api.listCommands(directory, workspace = null) suspend fun executeCommand(sessionId: String, request: ExecuteCommandRequest): MessageWrapperDto = - api.executeCommand(sessionId, request, directory) + api.executeCommand(sessionId, request, directory, workspace = null) suspend fun executeShellCommand(sessionId: String, request: ShellCommandRequest): MessageWrapperDto = - api.executeShellCommand(sessionId, request, directory) + api.executeShellCommand(sessionId, request, directory, workspace = null) - suspend fun listFiles(path: String): List = api.listFiles(path, directory) + suspend fun listFiles(path: String): List = api.listFiles(path, directory, workspace = null) - suspend fun readFile(path: String): FileContentDto = api.readFile(path, directory) + suspend fun readFile(path: String): FileContentDto = api.readFile(path, directory, workspace = null) - suspend fun getFileStatus(): List = api.getFileStatus(directory) + suspend fun getFileStatus(): List = api.getFileStatus(directory, workspace = null) - suspend fun searchSymbols(query: String): List = api.searchSymbols(query, directory) + suspend fun searchSymbols(query: String): List = api.searchSymbols(query, directory, workspace = null) } diff --git a/app/src/main/java/dev/blazelight/p4oc/di/KoinModules.kt b/app/src/main/java/dev/blazelight/p4oc/di/KoinModules.kt index 4a9e1a64..b75d7a19 100644 --- a/app/src/main/java/dev/blazelight/p4oc/di/KoinModules.kt +++ b/app/src/main/java/dev/blazelight/p4oc/di/KoinModules.kt @@ -29,6 +29,7 @@ import dev.blazelight.p4oc.ui.screens.settings.ChatSettingsViewModel import dev.blazelight.p4oc.ui.screens.settings.ModelControlsViewModel import dev.blazelight.p4oc.ui.screens.settings.NotificationSettingsViewModel import dev.blazelight.p4oc.ui.screens.settings.ProviderConfigViewModel +import dev.blazelight.p4oc.ui.screens.settings.SettingsConnectionContext import dev.blazelight.p4oc.ui.screens.settings.SettingsViewModel import dev.blazelight.p4oc.ui.screens.settings.SkillsViewModel import dev.blazelight.p4oc.ui.screens.settings.VisualSettingsViewModel @@ -40,6 +41,7 @@ import kotlinx.serialization.json.Json import org.koin.android.ext.koin.androidContext import org.koin.core.module.dsl.viewModel import org.koin.core.module.dsl.viewModelOf +import org.koin.core.parameter.parametersOf import org.koin.dsl.module val appModule = module { @@ -75,8 +77,7 @@ val networkModule = module { // Network single { MdnsDiscoveryManager(androidContext()) } - factory { PtyWebSocketClient(get()) } - single { ConnectionManager(get(), get(), get()) } + factory { params -> PtyWebSocketClient(get(), params.get(), params.get()) } single { ServerConnectionRegistry( settingsDataStore = get(), @@ -103,36 +104,64 @@ val networkModule = module { val viewModelModule = module { viewModelOf(::ServerViewModel) - viewModel { ModelControlsViewModel(get(), get()) } - viewModelOf(::AgentsConfigViewModel) + viewModel { (client: dev.blazelight.p4oc.data.workspace.WorkspaceClient) -> + ModelControlsViewModel( + client, + get(), + get(), + ) + } + viewModel { (client: dev.blazelight.p4oc.data.workspace.WorkspaceClient) -> AgentsConfigViewModel(client, get()) } viewModelOf(::VisualSettingsViewModel) viewModelOf(::ChatSettingsViewModel) - viewModelOf(::SkillsViewModel) - viewModelOf(::SettingsViewModel) + viewModel { (client: dev.blazelight.p4oc.data.workspace.WorkspaceClient) -> SkillsViewModel(client, get()) } + viewModel { params -> + SettingsViewModel( + settingsDataStore = get(), + serverConnectionRegistry = get(), + connectionContext = params.get(), + ) + } viewModelOf(::NotificationSettingsViewModel) viewModelOf(::LicensesViewModel) - viewModel { ProviderConfigViewModel(get(), get()) } - viewModelOf(::ProjectsViewModel) + viewModel { params -> + ProviderConfigViewModel( + params.get().workspaceClient, + get(), + get(), + ) + } + viewModel { params -> ProjectsViewModel(params.get(), get()) } viewModel { params -> WorkspaceViewModel(params.get()) } viewModel { params -> + val owner = params.get() ChatViewModel( - params.get(), - params.get(), - params.get(), - params.get(), get(), + owner.workspaceClient, + owner.sessionRepository, + owner.uploadCoordinator, get(), get(), - get() + get(), + get(), ) } viewModel { params -> SessionListViewModel(params.get(), get()) } viewModel { params -> FilesViewModel(params.get(), params.get(), get()) } - viewModel { params -> TerminalViewModel(params.get(), androidContext(), get(), get()) } + viewModel { params -> + val owner = params.get() + TerminalViewModel( + savedStateHandle = get(), + context = androidContext(), + ptyWebSocket = get { parametersOf(owner.workspace.server, owner.generation) }, + workspaceOwner = owner, + serverConnectionRegistry = get(), + ) + } } val allModules = listOf( diff --git a/app/src/main/java/dev/blazelight/p4oc/domain/model/Event.kt b/app/src/main/java/dev/blazelight/p4oc/domain/model/Event.kt index 79f724f9..dc66e35e 100644 --- a/app/src/main/java/dev/blazelight/p4oc/domain/model/Event.kt +++ b/app/src/main/java/dev/blazelight/p4oc/domain/model/Event.kt @@ -41,6 +41,17 @@ sealed class OpenCodeEvent { data class FileEdited(val file: String) : OpenCodeEvent() data class FileWatcherUpdated(val file: String, val event: String) : OpenCodeEvent() data class VcsBranchUpdated(val branch: String?) : OpenCodeEvent() + + // Project and catalog events (aligned with SDK) + data class ProjectUpdated(val project: Project) : OpenCodeEvent() + data class ProjectDirectoriesUpdated(val projectID: String) : OpenCodeEvent() + data object ModelsRefreshed : OpenCodeEvent() + data object CatalogUpdated : OpenCodeEvent() + data class McpToolsChanged(val server: String) : OpenCodeEvent() + + // Global lifecycle events (aligned with SDK) + data object GlobalDisposed : OpenCodeEvent() + data object Connected : OpenCodeEvent() data class Disconnected(val reason: String?) : OpenCodeEvent() data class Error(val throwable: Throwable) : OpenCodeEvent() diff --git a/app/src/main/java/dev/blazelight/p4oc/terminal/PtyTerminalClient.kt b/app/src/main/java/dev/blazelight/p4oc/terminal/PtyTerminalClient.kt index b8cb2712..8550ba9b 100644 --- a/app/src/main/java/dev/blazelight/p4oc/terminal/PtyTerminalClient.kt +++ b/app/src/main/java/dev/blazelight/p4oc/terminal/PtyTerminalClient.kt @@ -54,6 +54,7 @@ class PtyTerminalClient( } } + @Suppress("SwallowedException") override fun onPasteTextFromClipboard(session: TerminalSession?) { try { val clip = clipboardManager.primaryClip @@ -64,9 +65,9 @@ class PtyTerminalClient( } } } catch (e: SecurityException) { - AppLog.w("PtyTerminalClient", "Clipboard access denied - app may not be in focus", e) + AppLog.w("PtyTerminalClient", "Terminal warning") } catch (e: Exception) { - AppLog.e("PtyTerminalClient", "Failed to paste from clipboard", e) + AppLog.e("PtyTerminalClient", "Terminal error") } } @@ -75,30 +76,30 @@ class PtyTerminalClient( } override fun logError(tag: String?, message: String?) { - AppLog.e(tag ?: "PtyTerminalClient", message ?: "Unknown error") + AppLog.e("PtyTerminalClient", "Terminal library error") } override fun logWarn(tag: String?, message: String?) { - AppLog.w(tag ?: "PtyTerminalClient", message ?: "Unknown warning") + AppLog.w("PtyTerminalClient", "Terminal library warning") } override fun logInfo(tag: String?, message: String?) { - AppLog.i(tag ?: "PtyTerminalClient", message ?: "") + AppLog.i("PtyTerminalClient", "Terminal library info") } override fun logDebug(tag: String?, message: String?) { - AppLog.d(tag ?: "PtyTerminalClient", message ?: "") + AppLog.d("PtyTerminalClient", "Terminal library debug") } override fun logVerbose(tag: String?, message: String?) { - AppLog.v(tag ?: "PtyTerminalClient", message ?: "") + AppLog.v("PtyTerminalClient", "Terminal library trace") } override fun logStackTraceWithMessage(tag: String?, message: String?, e: Exception?) { - AppLog.e(tag ?: "PtyTerminalClient", message ?: "Unknown error", e) + AppLog.e(tag ?: "PtyTerminalClient", "Terminal error") } override fun logStackTrace(tag: String?, e: Exception?) { - AppLog.e(tag ?: "PtyTerminalClient", "Stack trace", e) + AppLog.e(tag ?: "PtyTerminalClient", "Terminal error") } } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/components/TermuxExtraKeysBar.kt b/app/src/main/java/dev/blazelight/p4oc/ui/components/TermuxExtraKeysBar.kt index 364691f1..fd6b6428 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/components/TermuxExtraKeysBar.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/components/TermuxExtraKeysBar.kt @@ -2,11 +2,14 @@ package dev.blazelight.p4oc.ui.components import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -19,6 +22,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.toggleableState +import androidx.compose.ui.state.ToggleableState import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextAlign import dev.blazelight.p4oc.ui.theme.SemanticColors @@ -56,31 +66,33 @@ fun TermuxExtraKeysBar( Row( modifier = Modifier .fillMaxWidth() - .height(Sizing.buttonHeightSm) + .height(Sizing.minTouchTarget) + .horizontalScroll(rememberScrollState()) ) { - ExtraKey("ESC", "\u001B", enabled, onKeyPress, Modifier.weight(1f)) - ExtraKey("/", "/", enabled, onKeyPress, Modifier.weight(1f)) - ExtraKey("―", "-", enabled, onKeyPress, Modifier.weight(1f)) - ExtraKey("HOME", "\u001B[H", enabled, onKeyPress, Modifier.weight(1f)) - RepeatableExtraKey("↑", "\u001B[A", enabled, onKeyPress, Modifier.weight(1f)) - ExtraKey("END", "\u001B[F", enabled, onKeyPress, Modifier.weight(1f)) - ExtraKey("PGUP", "\u001B[5~", enabled, onKeyPress, Modifier.weight(1f)) - ActionExtraKey("PST", enabled && onPaste != null, onPaste ?: {}, Modifier.weight(1f)) + ExtraKey("ESC", "\u001B", enabled, onKeyPress, Modifier.width(Sizing.minTouchTarget)) + ExtraKey("/", "/", enabled, onKeyPress, Modifier.width(Sizing.minTouchTarget)) + ExtraKey("―", "-", enabled, onKeyPress, Modifier.width(Sizing.minTouchTarget)) + ExtraKey("HOME", "\u001B[H", enabled, onKeyPress, Modifier.width(Sizing.minTouchTarget)) + RepeatableExtraKey("↑", "\u001B[A", enabled, onKeyPress, Modifier.width(Sizing.minTouchTarget)) + ExtraKey("END", "\u001B[F", enabled, onKeyPress, Modifier.width(Sizing.minTouchTarget)) + ExtraKey("PGUP", "\u001B[5~", enabled, onKeyPress, Modifier.width(Sizing.minTouchTarget)) + ActionExtraKey("PST", enabled && onPaste != null, onPaste ?: {}, Modifier.width(Sizing.minTouchTarget)) } // Row 2: TAB CTRL ALT ← ↓ → PGDN Row( modifier = Modifier .fillMaxWidth() - .height(Sizing.buttonHeightSm) + .height(Sizing.minTouchTarget) + .horizontalScroll(rememberScrollState()) ) { - ExtraKey("↹", "\t", enabled, onKeyPress, Modifier.weight(1f)) - ModifierKey("CTRL", ctrlActive, enabled, onCtrlToggle, Modifier.weight(1f)) - ModifierKey("ALT", altActive, enabled, onAltToggle, Modifier.weight(1f)) - RepeatableExtraKey("←", "\u001B[D", enabled, onKeyPress, Modifier.weight(1f)) - RepeatableExtraKey("↓", "\u001B[B", enabled, onKeyPress, Modifier.weight(1f)) - RepeatableExtraKey("→", "\u001B[C", enabled, onKeyPress, Modifier.weight(1f)) - ExtraKey("PGDN", "\u001B[6~", enabled, onKeyPress, Modifier.weight(1f)) + ExtraKey("↹", "\t", enabled, onKeyPress, Modifier.width(Sizing.minTouchTarget)) + ModifierKey("CTRL", ctrlActive, enabled, onCtrlToggle, Modifier.width(Sizing.minTouchTarget)) + ModifierKey("ALT", altActive, enabled, onAltToggle, Modifier.width(Sizing.minTouchTarget)) + RepeatableExtraKey("←", "\u001B[D", enabled, onKeyPress, Modifier.width(Sizing.minTouchTarget)) + RepeatableExtraKey("↓", "\u001B[B", enabled, onKeyPress, Modifier.width(Sizing.minTouchTarget)) + RepeatableExtraKey("→", "\u001B[C", enabled, onKeyPress, Modifier.width(Sizing.minTouchTarget)) + ExtraKey("PGDN", "\u001B[6~", enabled, onKeyPress, Modifier.width(Sizing.minTouchTarget)) } } } @@ -100,6 +112,14 @@ private fun ExtraKey( Box( modifier = modifier + .semantics { + role = Role.Button + if (!enabled) disabled() + onClick(label) { + if (enabled) onKeyPress(sequence) + enabled + } + } .background( color = if (isPressed) SemanticColors.TerminalKeys.keyPressed else Color.Transparent, shape = RectangleShape @@ -146,6 +166,14 @@ private fun ActionExtraKey( Box( modifier = modifier + .semantics { + role = Role.Button + if (!enabled) disabled() + onClick(label) { + if (enabled) onClick() + enabled + } + } .background( color = if (isPressed) SemanticColors.TerminalKeys.keyPressed else Color.Transparent, shape = RectangleShape @@ -195,6 +223,14 @@ private fun RepeatableExtraKey( Box( modifier = modifier + .semantics { + role = Role.Button + if (!enabled) disabled() + onClick(label) { + if (enabled) onKeyPress(sequence) + enabled + } + } .background( color = if (isPressed) SemanticColors.TerminalKeys.keyPressed else Color.Transparent, shape = RectangleShape @@ -258,6 +294,15 @@ private fun ModifierKey( Box( modifier = modifier + .semantics { + role = Role.Checkbox + toggleableState = ToggleableState(active) + if (!enabled) disabled() + onClick(label) { + if (enabled) onClick() + enabled + } + } .background( color = if (isPressed) SemanticColors.TerminalKeys.keyPressed else Color.Transparent, shape = RectangleShape diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/components/TermuxTerminalView.kt b/app/src/main/java/dev/blazelight/p4oc/ui/components/TermuxTerminalView.kt index fb340de6..99a22f45 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/components/TermuxTerminalView.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/components/TermuxTerminalView.kt @@ -16,11 +16,19 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.viewinterop.AndroidView import com.termux.terminal.TerminalEmulator import com.termux.terminal.TerminalSession import com.termux.view.TerminalView import com.termux.view.TerminalViewClient +import dev.blazelight.p4oc.R import dev.blazelight.p4oc.core.log.AppLog class KeyInterceptingContainer(context: Context) : FrameLayout(context) { @@ -170,14 +178,20 @@ class TerminalInputView(context: Context) : View(context) { } @Composable +@Suppress("FunctionNaming", "LongMethod", "LongParameterList") fun TermuxTerminalView( emulator: TerminalEmulator?, onKeyInput: (String) -> Unit, + accessibleScreenText: String, modifier: Modifier = Modifier, onTerminalViewReady: ((TerminalView) -> Unit)? = null, onTerminalSizeChanged: ((rows: Int, cols: Int) -> Unit)? = null, ) { val context = LocalContext.current + val accessibilityLabel = stringResource(R.string.terminal_accessibility_label) + val connectedState = stringResource(R.string.terminal_accessibility_connected) + val focusInputLabel = stringResource(R.string.terminal_accessibility_focus_input) + val inputViewHolder = remember { arrayOfNulls(1) } val terminalViewClient = remember(onKeyInput) { createTerminalViewClient(context, onKeyInput) @@ -185,19 +199,27 @@ fun TermuxTerminalView( AndroidView( factory = { ctx -> - val container = FrameLayout(ctx) + val container = KeyInterceptingContainer(ctx).apply { + this.onKeyInput = onKeyInput + } val terminalView = TerminalView(ctx, null).apply { setTextSize(14) setTypeface(Typeface.MONOSPACE) setTerminalViewClient(terminalViewClient) keepScreenOn = true + // Compose exposes the bounded terminal identity and state. Do not let the native + // view publish its potentially unbounded scrollback as a second semantics tree. + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO } val inputView = TerminalInputView(ctx).apply { this.onKeyInput = onKeyInput layoutParams = FrameLayout.LayoutParams(1, 1) + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO } + inputViewHolder[0] = inputView + container.terminalView = terminalView container.addView( terminalView, @@ -210,9 +232,7 @@ fun TermuxTerminalView( terminalView.setOnTouchListener { _, event -> if (event.action == MotionEvent.ACTION_UP) { - inputView.requestFocus() - val imm = ctx.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - imm.showSoftInput(inputView, InputMethodManager.SHOW_IMPLICIT) + focusTerminalInput(inputView) } false } @@ -227,6 +247,7 @@ fun TermuxTerminalView( // Update the input callback on recomposition to avoid stale callbacks views?.second?.onKeyInput = onKeyInput + container.onKeyInput = onKeyInput emulator?.let { emu -> views?.first?.let { view -> @@ -244,10 +265,27 @@ fun TermuxTerminalView( } } }, - modifier = modifier + modifier = modifier.clearAndSetSemantics { + contentDescription = if (accessibleScreenText.isBlank()) { + accessibilityLabel + } else { + "$accessibilityLabel\n$accessibleScreenText" + } + stateDescription = connectedState + liveRegion = LiveRegionMode.Polite + onClick(focusInputLabel) { + inputViewHolder[0]?.let(::focusTerminalInput) != null + } + } ) } +private fun focusTerminalInput(inputView: TerminalInputView) { + inputView.requestFocus() + val imm = inputView.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.showSoftInput(inputView, InputMethodManager.SHOW_IMPLICIT) +} + private fun notifyTerminalSizeChanged( view: TerminalView, onTerminalSizeChanged: ((rows: Int, cols: Int) -> Unit)?, @@ -352,11 +390,11 @@ private fun createTerminalViewClient( } override fun logStackTraceWithMessage(tag: String?, message: String?, e: Exception?) { - AppLog.e(tag ?: "TerminalView", message ?: "Unknown error", e) + AppLog.e(tag ?: "TerminalView", "Terminal error") } override fun logStackTrace(tag: String?, e: Exception?) { - AppLog.e(tag ?: "TerminalView", "Stack trace", e) + AppLog.e(tag ?: "TerminalView", "Terminal error") } } } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/components/TuiComponents.kt b/app/src/main/java/dev/blazelight/p4oc/ui/components/TuiComponents.kt index 68b09c44..3d0e0323 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/components/TuiComponents.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/components/TuiComponents.kt @@ -109,7 +109,7 @@ fun TuiOutlinedCard( // ============================================================================= /** - * TUI-style filled button with compact height. + * TUI-style filled button with compact chrome and an accessible touch target. */ @Composable fun TuiButton( @@ -122,7 +122,9 @@ fun TuiButton( ) { Button( onClick = onClick, - modifier = modifier.height(Sizing.buttonHeightMd), + modifier = modifier + .minimumInteractiveComponentSize() + .heightIn(min = Sizing.minTouchTarget), enabled = enabled, colors = colors, shape = RectangleShape, @@ -145,7 +147,9 @@ fun TuiOutlinedButton( ) { OutlinedButton( onClick = onClick, - modifier = modifier.height(Sizing.buttonHeightMd), + modifier = modifier + .minimumInteractiveComponentSize() + .heightIn(min = Sizing.minTouchTarget), enabled = enabled, colors = colors, shape = RectangleShape, @@ -168,7 +172,9 @@ fun TuiTextButton( ) { TextButton( onClick = onClick, - modifier = modifier.height(Sizing.buttonHeightMd), + modifier = modifier + .minimumInteractiveComponentSize() + .heightIn(min = Sizing.minTouchTarget), enabled = enabled, colors = colors, shape = RectangleShape, @@ -190,7 +196,12 @@ fun TuiIconButton( ) { IconButton( onClick = onClick, - modifier = modifier.size(Sizing.iconButtonMd), + modifier = modifier + .minimumInteractiveComponentSize() + .sizeIn( + minWidth = Sizing.minTouchTarget, + minHeight = Sizing.minTouchTarget, + ), enabled = enabled, colors = colors, content = content diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ChatInputBar.kt b/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ChatInputBar.kt index 8770c858..912cda3d 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ChatInputBar.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ChatInputBar.kt @@ -2,7 +2,6 @@ package dev.blazelight.p4oc.ui.components.chat import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState @@ -27,7 +26,6 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.TextStyle @@ -35,6 +33,7 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp import dev.blazelight.p4oc.R import dev.blazelight.p4oc.domain.model.Command import dev.blazelight.p4oc.ui.components.TuiLoadingIndicator @@ -181,42 +180,55 @@ fun ChatInputBar( attachedFiles.forEach { file -> val chipColor = if (file.available) theme.accent else theme.warning val chipLabelColor = if (file.available) theme.text else theme.warning - Surface( - shape = RectangleShape, - color = chipColor.copy(alpha = 0.1f), - modifier = Modifier - .height(Sizing.buttonHeightSm) - .border(Sizing.strokeMd, chipColor, RectangleShape) - ) { - Row( - modifier = Modifier.padding(horizontal = Spacing.mdLg), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(Spacing.sm) + val removeDescription = stringResource( + R.string.chat_action_remove_attachment, + file.name, + ) + Box(modifier = Modifier.height(48.dp)) { + Surface( + shape = RectangleShape, + color = chipColor.copy(alpha = 0.1f), + modifier = Modifier + .align(Alignment.Center) + .height(Sizing.buttonHeightSm) + .border(Sizing.strokeMd, chipColor, RectangleShape) ) { - Text( - file.name, - style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace, - color = chipLabelColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.widthIn(max = Sizing.panelWidthMd) - ) - if (!file.available) { + Row( + modifier = Modifier.padding(start = Spacing.mdLg), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.sm) + ) { Text( - text = stringResource(R.string.attachment_unavailable), - style = MaterialTheme.typography.labelSmall, + file.name, + style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace, - color = theme.warning, + color = chipLabelColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = Sizing.panelWidthMd) ) + if (!file.available) { + Text( + text = stringResource(R.string.attachment_unavailable), + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = theme.warning, + ) + } + Spacer(modifier = Modifier.width(Sizing.iconButtonMd)) } + } + IconButton( + onClick = { onRemoveAttachment(file.path) }, + modifier = Modifier + .align(Alignment.CenterEnd) + .size(48.dp) + .semantics { contentDescription = removeDescription } + ) { Text( text = "×", color = theme.textMuted, fontFamily = FontFamily.Monospace, - modifier = Modifier.clickable( - role = Role.Button - ) { onRemoveAttachment(file.path) } ) } } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ChatMessage.kt b/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ChatMessage.kt index cb7c8773..ac4f5178 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ChatMessage.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ChatMessage.kt @@ -33,12 +33,14 @@ import dev.blazelight.p4oc.ui.theme.Sizing import dev.blazelight.p4oc.ui.theme.Spacing @Composable +@Suppress("LongParameterList", "FunctionNaming") fun ChatMessage( messageWithParts: MessageWithParts, onToolApprove: (String) -> Unit, onToolDeny: (String) -> Unit, onToolAlways: (String) -> Unit, onOpenSubSession: ((String) -> Unit)? = null, + onProviderAuthRequired: ((String) -> Unit)? = null, defaultToolWidgetState: ToolWidgetState = ToolWidgetState.COMPACT, pendingPermissionsByCallId: Map = emptyMap(), onRevert: (() -> Unit)? = null, @@ -60,6 +62,7 @@ fun ChatMessage( onToolDeny = onToolDeny, onToolAlways = onToolAlways, onOpenSubSession = onOpenSubSession, + onProviderAuthRequired = onProviderAuthRequired, defaultToolWidgetState = defaultToolWidgetState, pendingPermissionsByCallId = pendingPermissionsByCallId, ) @@ -68,12 +71,14 @@ fun ChatMessage( } @Composable +@Suppress("LongParameterList", "FunctionNaming") fun AssistantMessages( messagesWithParts: List, onToolApprove: (String) -> Unit, onToolDeny: (String) -> Unit, onToolAlways: (String) -> Unit, onOpenSubSession: ((String) -> Unit)? = null, + onProviderAuthRequired: ((String) -> Unit)? = null, defaultToolWidgetState: ToolWidgetState = ToolWidgetState.COMPACT, pendingPermissionsByCallId: Map = emptyMap(), modifier: Modifier = Modifier @@ -91,6 +96,7 @@ fun AssistantMessages( onToolDeny = onToolDeny, onToolAlways = onToolAlways, onOpenSubSession = onOpenSubSession, + onProviderAuthRequired = onProviderAuthRequired, defaultToolWidgetState = defaultToolWidgetState, pendingPermissionsByCallId = pendingPermissionsByCallId, ) @@ -191,43 +197,20 @@ private fun UserMessage( } @Composable +@Suppress("LongParameterList", "FunctionNaming") private fun AssistantMessageContent( messageWithParts: MessageWithParts, onToolApprove: (String) -> Unit, onToolDeny: (String) -> Unit, onToolAlways: (String) -> Unit, onOpenSubSession: ((String) -> Unit)? = null, + onProviderAuthRequired: ((String) -> Unit)? = null, defaultToolWidgetState: ToolWidgetState = ToolWidgetState.COMPACT, pendingPermissionsByCallId: Map = emptyMap(), ) { // Build ordered groups: consecutive tools get batched, non-tools rendered individually // Invisible parts (StepStart, StepFinish, Snapshot, etc.) don't break tool groups - val partGroups = buildList { - var currentToolBatch = mutableListOf() - - for (part in messageWithParts.parts) { - when (part) { - is Part.Tool -> currentToolBatch.add(part) - // Invisible parts - don't break tool groups, just skip - is Part.StepStart, is Part.StepFinish, is Part.Snapshot, - is Part.Agent, is Part.Retry, is Part.Compaction, is Part.Subtask -> { - // Skip - truly invisible - } - // Visible parts - flush tools before rendering - else -> { - if (currentToolBatch.isNotEmpty()) { - add(PartGroupItem.Tools(currentToolBatch.toList())) - currentToolBatch = mutableListOf() - } - add(PartGroupItem.Other(part)) - } - } - } - // Flush any trailing tools - if (currentToolBatch.isNotEmpty()) { - add(PartGroupItem.Tools(currentToolBatch.toList())) - } - } + val partGroups = buildPartGroups(messageWithParts.parts) Column( modifier = Modifier.fillMaxWidth(), @@ -244,34 +227,81 @@ private fun AssistantMessageContent( pendingPermissionIdsByCallId = pendingPermissionsByCallId.mapValues { it.value.id }, onToolApprove = onToolApprove, onToolDeny = onToolDeny, + onToolAlways = onToolAlways, onOpenSubSession = onOpenSubSession ) } - is PartGroupItem.Other -> { - when (val part = group.part) { - is Part.Text -> TextPart(part) - is Part.Reasoning -> ReasoningPart(part) - is Part.File -> FilePart(part) - is Part.Patch -> CompactPatchPart(part) - else -> {} // Already handled invisible parts above - } - } + is PartGroupItem.Other -> renderOtherPart(group.part) } } (messageWithParts.message as? Message.Assistant)?.error?.let { error -> - AssistantError(error) + AssistantError(error, onProviderAuthRequired) } } } +private fun buildPartGroups(parts: List): List = buildList { + var currentToolBatch = mutableListOf() + + for (part in parts) { + when (part) { + is Part.Tool -> currentToolBatch.add(part) + // Invisible parts - don't break tool groups, just skip + is Part.StepStart, is Part.StepFinish, is Part.Snapshot, + is Part.Agent -> { + // Skip - truly invisible + } + // Visible parts - flush tools before rendering + else -> { + if (currentToolBatch.isNotEmpty()) { + add(PartGroupItem.Tools(currentToolBatch.toList())) + currentToolBatch = mutableListOf() + } + add(PartGroupItem.Other(part)) + } + } + } + // Flush any trailing tools + if (currentToolBatch.isNotEmpty()) { + add(PartGroupItem.Tools(currentToolBatch.toList())) + } +} + @Composable -private fun AssistantError(error: MessageError) { +private fun renderOtherPart(part: Part) { + when (part) { + is Part.Text -> TextPart(part) + is Part.Reasoning -> ReasoningPart(part) + is Part.File -> FilePart(part) + is Part.Patch -> CompactPatchPart(part) + is Part.Subtask -> activityMarker(stringResource(R.string.chat_delegated_to, part.agent, part.description)) + is Part.Retry -> activityMarker(stringResource(R.string.chat_retry_attempt, part.attempt)) + is Part.Compaction -> activityMarker(stringResource(R.string.chat_context_compacted)) + else -> Unit + } +} + +@Composable +private fun activityMarker(label: String) { + val theme = LocalOpenCodeTheme.current + Text( + text = label, + modifier = Modifier.fillMaxWidth().padding(vertical = Spacing.xs), + style = MaterialTheme.typography.labelSmall, + color = theme.textMuted, + ) +} + +@Composable +@Suppress("FunctionNaming") +private fun AssistantError(error: MessageError, onProviderAuthRequired: ((String) -> Unit)? = null) { val theme = LocalOpenCodeTheme.current val message = when { - error.name == "MessageAbortedError" -> "Run aborted" - !error.message.isNullOrBlank() -> error.message - else -> "Run failed" + error.name == "MessageAbortedError" -> stringResource(R.string.chat_run_aborted) + error.name == "ProviderAuthError" -> stringResource(R.string.chat_provider_auth_required) + error.isRetryable -> stringResource(R.string.chat_run_retryable_error) + else -> stringResource(R.string.chat_run_failed) } Box( @@ -280,11 +310,23 @@ private fun AssistantError(error: MessageError) { .background(theme.error.copy(alpha = 0.1f)) .padding(horizontal = Spacing.sm, vertical = Spacing.xs) ) { - Text( - text = message, - style = MaterialTheme.typography.bodySmall, - color = theme.error - ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.sm) + ) { + Text( + text = message, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodySmall, + color = theme.error + ) + if (error.name == "ProviderAuthError" && error.providerID != null && onProviderAuthRequired != null) { + TextButton(onClick = { onProviderAuthRequired(error.providerID) }) { + Text(stringResource(R.string.provider_auth_action)) + } + } + } } } @@ -360,7 +402,7 @@ private fun ReasoningPart(part: Part.Reasoning) { } Text( - text = "Reasoning", + text = stringResource(R.string.models_reasoning), style = MaterialTheme.typography.labelSmall, color = theme.warning, modifier = Modifier.weight(1f) @@ -459,7 +501,7 @@ private fun CompactPatchPart(part: Part.Patch) { tint = theme.accent ) Text( - text = "Patch: ${part.files.size} file(s)", + text = stringResource(R.string.chat_patch_files, part.files.size), style = MaterialTheme.typography.labelSmall, color = theme.text, modifier = Modifier.weight(1f) diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/InlinePermissionPrompt.kt b/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/InlinePermissionPrompt.kt index 0296ac99..8f7c0aaf 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/InlinePermissionPrompt.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/InlinePermissionPrompt.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import dev.blazelight.p4oc.R @@ -68,7 +69,9 @@ fun InlinePermissionPrompt( onClick = onReject, modifier = Modifier .weight(1f) - .height(Sizing.buttonHeightSm), + .minimumInteractiveComponentSize() + .heightIn(min = Sizing.minTouchTarget) + .testTag("permission_deny_${permission.id}"), shape = RectangleShape, contentPadding = PaddingValues(horizontal = Spacing.sm, vertical = Spacing.none), colors = ButtonDefaults.outlinedButtonColors( @@ -85,7 +88,9 @@ fun InlinePermissionPrompt( onClick = onAlways, modifier = Modifier .weight(1f) - .height(Sizing.buttonHeightSm), + .minimumInteractiveComponentSize() + .heightIn(min = Sizing.minTouchTarget) + .testTag("permission_always_allow_${permission.id}"), shape = RectangleShape, contentPadding = PaddingValues(horizontal = Spacing.sm, vertical = Spacing.none) ) { @@ -99,7 +104,9 @@ fun InlinePermissionPrompt( onClick = onAllow, modifier = Modifier .weight(1f) - .height(Sizing.buttonHeightSm), + .minimumInteractiveComponentSize() + .heightIn(min = Sizing.minTouchTarget) + .testTag("permission_allow_once_${permission.id}"), shape = RectangleShape, contentPadding = PaddingValues(horizontal = Spacing.sm, vertical = Spacing.none), colors = ButtonDefaults.buttonColors( diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ModelAgentSelector.kt b/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ModelAgentSelector.kt index bb178af2..bd519894 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ModelAgentSelector.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ModelAgentSelector.kt @@ -5,6 +5,8 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material3.* @@ -15,6 +17,9 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.window.Dialog import dev.blazelight.p4oc.R @@ -34,7 +39,9 @@ private fun getAgentColor(agent: AgentDto?): Color { agent.color?.let { hex -> try { return Color(android.graphics.Color.parseColor(hex)) - } catch (_: Exception) {} + } catch (_: IllegalArgumentException) { + // Server-provided colors are optional; fall back to the stable name-derived color. + } } return SemanticColors.AgentSelector.forName(agent.name) @@ -53,7 +60,7 @@ data class EnhancedModelInfo( ) @Composable -@Suppress("LongParameterList", "LongMethod", "FunctionNaming") +@Suppress("CyclomaticComplexMethod", "LongParameterList", "LongMethod", "FunctionNaming") fun ModelAgentSelectorBar( availableAgents: List, selectedAgent: String?, @@ -70,6 +77,7 @@ fun ModelAgentSelectorBar( ) { val theme = LocalOpenCodeTheme.current var showModelPicker by remember { mutableStateOf(false) } + var showAgentPicker by remember { mutableStateOf(false) } val selectModelText = stringResource(R.string.select_model) val selectedModelDto = remember(selectedModel, availableModels) { @@ -102,28 +110,78 @@ fun ModelAgentSelectorBar( if (availableAgents.isNotEmpty()) { val currentAgent = availableAgents.find { it.name == selectedAgent } val agentColor = getAgentColor(currentAgent) + val currentAgentName = (selectedAgent ?: availableAgents.first().name).lowercase() + val agentSelectorDescription = stringResource( + R.string.agent_selector_current, + currentAgentName + ) - Surface( - onClick = { - val currentIndex = availableAgents.indexOfFirst { it.name == selectedAgent } - val nextIndex = (currentIndex + 1) % availableAgents.size - onAgentSelected(availableAgents[nextIndex].name) - }, - shape = androidx.compose.ui.graphics.RectangleShape, - color = agentColor.copy(alpha = 0.1f), - border = androidx.compose.foundation.BorderStroke(Sizing.strokeMd, agentColor.copy(alpha = 0.4f)), - modifier = Modifier.height(Sizing.buttonHeightMd) - ) { - Box( - modifier = Modifier.padding(horizontal = Spacing.lg), - contentAlignment = Alignment.Center + Box { + Surface( + onClick = { showAgentPicker = true }, + shape = RectangleShape, + color = agentColor.copy(alpha = 0.1f), + border = androidx.compose.foundation.BorderStroke( + Sizing.strokeMd, + agentColor.copy(alpha = 0.4f) + ), + modifier = Modifier + .height(Sizing.buttonHeightMd) + .semantics { contentDescription = agentSelectorDescription } ) { - Text( - text = "@${(selectedAgent ?: "build").lowercase()}", - style = MaterialTheme.typography.labelMedium, - fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, - color = agentColor - ) + Row( + modifier = Modifier.padding(horizontal = Spacing.lg), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.sm) + ) { + Text( + text = "@$currentAgentName", + style = MaterialTheme.typography.labelMedium, + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + color = agentColor + ) + Text( + text = "▾", + color = agentColor, + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace + ) + } + } + DropdownMenu( + expanded = showAgentPicker, + onDismissRequest = { showAgentPicker = false } + ) { + availableAgents.forEach { agent -> + DropdownMenuItem( + text = { + Column(modifier = Modifier.widthIn(max = Sizing.panelWidthLg)) { + Text( + text = "@${agent.name.lowercase()}", + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + style = MaterialTheme.typography.labelLarge + ) + agent.description?.takeIf { it.isNotBlank() }?.let { description -> + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = theme.textMuted, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } + }, + onClick = { + onAgentSelected(agent.name) + showAgentPicker = false + }, + leadingIcon = if (agent.name == selectedAgent) { + { Text("✓", color = getAgentColor(agent)) } + } else { + null + } + ) + } } } } @@ -343,11 +401,12 @@ fun ModelPickerDialog( Row( modifier = Modifier .horizontalScroll(rememberScrollState()) - .padding(horizontal = Spacing.md, vertical = Spacing.xs), + .padding(horizontal = Spacing.md, vertical = Spacing.xs) + .selectableGroup(), horizontalArrangement = Arrangement.spacedBy(Spacing.xs) ) { TuiFilterTab( - text = "all", + text = stringResource(R.string.all), selected = selectedCategory == null, onClick = { selectedCategory = null } ) @@ -366,7 +425,9 @@ fun ModelPickerDialog( // Model list LazyColumn( - modifier = Modifier.weight(1f), + modifier = Modifier + .weight(1f) + .selectableGroup(), contentPadding = PaddingValues(vertical = Spacing.xs) ) { if (favorites.isNotEmpty()) { @@ -518,14 +579,18 @@ private fun TuiFilterTab( ) { val theme = LocalOpenCodeTheme.current Surface( - onClick = onClick, color = if (selected) theme.accent.copy(alpha = 0.15f) else Color.Transparent, shape = RectangleShape, border = if (selected) { androidx.compose.foundation.BorderStroke(Sizing.strokeMd, theme.accent.copy(alpha = 0.5f)) } else { null - } + }, + modifier = Modifier.selectable( + selected = selected, + onClick = onClick, + role = Role.Tab, + ), ) { Text( text = text, @@ -557,11 +622,18 @@ private fun TuiModelListItem( onToggleFavorite: () -> Unit ) { val theme = LocalOpenCodeTheme.current + val addFavoriteDescription = stringResource(R.string.cd_add_to_favorites) + val removeFavoriteDescription = stringResource(R.string.cd_remove_from_favorites) Surface( - onClick = onSelect, color = if (isSelected) theme.accent.copy(alpha = 0.1f) else Color.Transparent, - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = isSelected, + onClick = onSelect, + role = Role.RadioButton, + ) ) { Row( modifier = Modifier @@ -642,7 +714,15 @@ private fun TuiModelListItem( // Favorite button IconButton( onClick = onToggleFavorite, - modifier = Modifier.size(Sizing.iconButtonSm) + modifier = Modifier + .size(Sizing.iconButtonSm) + .semantics { + contentDescription = if (model.isFavorite) { + removeFavoriteDescription + } else { + addFavoriteDescription + } + } ) { Text( text = if (model.isFavorite) "★" else "☆", diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/SlashCommandsPopup.kt b/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/SlashCommandsPopup.kt index d28cceb6..f920d7af 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/SlashCommandsPopup.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/SlashCommandsPopup.kt @@ -214,8 +214,9 @@ private fun SlashCommandError( contentDescription = stringResource(R.string.slash_commands_retry_loading), tint = theme.accent, modifier = Modifier - .size(Sizing.iconSm) + .size(Sizing.minTouchTarget) .clickable(role = Role.Button, onClick = onRetry) + .padding((Sizing.minTouchTarget - Sizing.iconSm) / 2) ) } } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ToolComponents.kt b/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ToolComponents.kt index 9a58a520..843306f5 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ToolComponents.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/components/chat/ToolComponents.kt @@ -366,7 +366,7 @@ fun ToolOutputDialog( HorizontalDivider(color = theme.border) - if (hasDiff && diffContent != null) { + if (hasDiff) { Column( modifier = Modifier .weight(1f) @@ -555,7 +555,7 @@ fun EnhancedToolPart( verticalArrangement = Arrangement.spacedBy(Spacing.md) ) { if (hasDiff && state is ToolState.Completed) { - val diffContent = metadata?.get("diff")?.jsonPrimitive?.contentOrNull + val diffContent = metadata.get("diff")?.jsonPrimitive?.contentOrNull if (diffContent != null) { DiffPreview( diffContent = diffContent, diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/components/toolwidgets/ExpandedWidgets.kt b/app/src/main/java/dev/blazelight/p4oc/ui/components/toolwidgets/ExpandedWidgets.kt index 6e5d651f..ef9b9303 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/components/toolwidgets/ExpandedWidgets.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/components/toolwidgets/ExpandedWidgets.kt @@ -490,7 +490,7 @@ fun TaskWidgetExpanded( color = color ) Text( - text = "Task", + text = stringResource(R.string.task), style = MaterialTheme.typography.labelMedium.copy( fontFamily = FontFamily.Monospace, fontSize = TuiCodeFontSize.lg diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/components/toolwidgets/ToolGroupWidget.kt b/app/src/main/java/dev/blazelight/p4oc/ui/components/toolwidgets/ToolGroupWidget.kt index 34c8aeb9..6226fe69 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/components/toolwidgets/ToolGroupWidget.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/components/toolwidgets/ToolGroupWidget.kt @@ -13,8 +13,11 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontFamily @@ -57,12 +60,14 @@ private data class ToolGroup( * Expanded: HUD + full tool widgets */ @Composable +@Suppress("CyclomaticComplexMethod", "LongParameterList", "LongMethod", "FunctionNaming") fun ToolGroupWidget( tools: List, defaultState: ToolWidgetState, pendingPermissionIdsByCallId: Map = emptyMap(), onToolApprove: (String) -> Unit, onToolDeny: (String) -> Unit, + onToolAlways: (String) -> Unit, onOpenSubSession: ((String) -> Unit)? = null, modifier: Modifier = Modifier ) { @@ -150,6 +155,7 @@ fun ToolGroupWidget( Row( modifier = Modifier .fillMaxWidth() + .heightIn(min = Sizing.minTouchTarget) .background(theme.backgroundPanel.copy(alpha = 0.5f)) .clickable(role = Role.Button) { currentState = currentState.next() } .padding(horizontal = Spacing.sm, vertical = Spacing.xxs), @@ -193,12 +199,16 @@ fun ToolGroupWidget( // Show approval buttons for live or recovered pending permissions. if (tool.callID in pendingPermissionIdsByCallId.keys) { PendingApprovalButtonsInline( + requestId = pendingPermissionIdsByCallId[tool.callID] ?: tool.callID, onApprove = { onToolApprove( pendingPermissionIdsByCallId[tool.callID] ?: tool.callID ) }, - onDeny = { onToolDeny(pendingPermissionIdsByCallId[tool.callID] ?: tool.callID) } + onAlways = { + onToolAlways(pendingPermissionIdsByCallId[tool.callID] ?: tool.callID) + }, + onDeny = { onToolDeny(pendingPermissionIdsByCallId[tool.callID] ?: tool.callID) }, ) } } @@ -207,13 +217,27 @@ fun ToolGroupWidget( ToolCallExpanded( tool = tool, onClick = { currentState = currentState.next() }, - showApprovalActions = tool.callID in pendingPermissionIdsByCallId.keys, + showApprovalActions = false, approvalRequestId = pendingPermissionIdsByCallId[tool.callID] ?: tool.callID, onToolApprove = onToolApprove, onToolDeny = onToolDeny, onOpenSubSession = onOpenSubSession, modifier = Modifier.fillMaxWidth() ) + if (tool.callID in pendingPermissionIdsByCallId.keys) { + PendingApprovalButtonsInline( + requestId = pendingPermissionIdsByCallId[tool.callID] ?: tool.callID, + onApprove = { + onToolApprove(pendingPermissionIdsByCallId[tool.callID] ?: tool.callID) + }, + onAlways = { + onToolAlways(pendingPermissionIdsByCallId[tool.callID] ?: tool.callID) + }, + onDeny = { + onToolDeny(pendingPermissionIdsByCallId[tool.callID] ?: tool.callID) + }, + ) + } } else -> {} // Oneline handled above } @@ -224,8 +248,11 @@ fun ToolGroupWidget( } @Composable +@Suppress("FunctionNaming") private fun PendingApprovalButtonsInline( + requestId: String, onApprove: () -> Unit, + onAlways: () -> Unit, onDeny: () -> Unit ) { val theme = LocalOpenCodeTheme.current @@ -234,14 +261,16 @@ private fun PendingApprovalButtonsInline( .fillMaxWidth() .background(theme.secondary.copy(alpha = 0.2f)) .padding(horizontal = Spacing.md, vertical = Spacing.xs), - horizontalArrangement = Arrangement.spacedBy(Spacing.md) + horizontalArrangement = Arrangement.spacedBy(Spacing.xs) ) { OutlinedButton( onClick = onDeny, modifier = Modifier .weight(1f) - .height(Sizing.chipHeight), - contentPadding = PaddingValues(horizontal = Spacing.md, vertical = Spacing.none), + .heightIn(min = Sizing.minTouchTarget) + .semantics { contentDescription = "Deny permission $requestId" } + .testTag("tool_permission_deny_$requestId"), + contentPadding = PaddingValues(horizontal = Spacing.xs, vertical = Spacing.none), shape = RectangleShape ) { Text(stringResource(R.string.deny), style = MaterialTheme.typography.labelSmall) @@ -250,11 +279,25 @@ private fun PendingApprovalButtonsInline( onClick = onApprove, modifier = Modifier .weight(1f) - .height(Sizing.chipHeight), - contentPadding = PaddingValues(horizontal = Spacing.md, vertical = Spacing.none), + .heightIn(min = Sizing.minTouchTarget) + .semantics { contentDescription = "Allow permission once $requestId" } + .testTag("tool_permission_allow_once_$requestId"), + contentPadding = PaddingValues(horizontal = Spacing.xs, vertical = Spacing.none), shape = RectangleShape ) { Text(stringResource(R.string.allow), style = MaterialTheme.typography.labelSmall) } + OutlinedButton( + onClick = onAlways, + modifier = Modifier + .weight(1f) + .heightIn(min = Sizing.minTouchTarget) + .semantics { contentDescription = "Always allow permission $requestId" } + .testTag("tool_permission_allow_always_$requestId"), + contentPadding = PaddingValues(horizontal = Spacing.xs, vertical = Spacing.none), + shape = RectangleShape, + ) { + Text(stringResource(R.string.always_allow), style = MaterialTheme.typography.labelSmall) + } } } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/navigation/NavGraph.kt b/app/src/main/java/dev/blazelight/p4oc/ui/navigation/NavGraph.kt index 2a408d91..7e97c79c 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/navigation/NavGraph.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/navigation/NavGraph.kt @@ -1,19 +1,27 @@ package dev.blazelight.p4oc.ui.navigation -import androidx.compose.animation.* import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally import androidx.compose.runtime.Composable import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import dev.blazelight.p4oc.core.network.ServerConnectionRegistry +import dev.blazelight.p4oc.core.notification.NotificationRoute import dev.blazelight.p4oc.ui.screens.server.serverScreen -import dev.blazelight.p4oc.ui.screens.settings.ProviderConfigScreen +import dev.blazelight.p4oc.ui.screens.settings.SettingsConnectionContext import dev.blazelight.p4oc.ui.screens.settings.SettingsScreen +import dev.blazelight.p4oc.ui.screens.settings.SettingsViewModel import dev.blazelight.p4oc.ui.screens.settings.VisualSettingsScreen import dev.blazelight.p4oc.ui.screens.setup.SetupScreen import dev.blazelight.p4oc.ui.tabs.MainTabScreen +import kotlinx.coroutines.flow.StateFlow +import org.koin.androidx.compose.koinViewModel import org.koin.compose.koinInject +import org.koin.core.parameter.parametersOf private const val ANIMATION_DURATION = 300 @@ -23,9 +31,12 @@ private const val ANIMATION_DURATION = 300 * which manages its own per-tab navigation. */ @Composable +@Suppress("FunctionNaming", "LongMethod") fun NavGraph( navController: NavHostController, - startDestination: String + startDestination: String, + pendingNotificationRoute: StateFlow, + onNotificationRouteConsumed: (NotificationRoute) -> Unit, ) { NavHost( navController = navController, @@ -100,6 +111,8 @@ fun NavGraph( // Main tab container - this is where the tab-based UI lives composable(Screen.Sessions.route) { MainTabScreen( + pendingNotificationRoute = pendingNotificationRoute, + onNotificationRouteConsumed = onNotificationRouteConsumed, onDisconnect = { navController.navigate(Screen.ServerManagement.route) } @@ -109,15 +122,15 @@ fun NavGraph( // Settings accessible from Server screen (before connecting) composable(Screen.Settings.route) { SettingsScreen( + viewModel = koinViewModel { + parametersOf(SettingsConnectionContext.Global) + }, onNavigateBack = { navController.popBackStack() }, onDisconnect = { navController.navigate(Screen.Server.route) { popUpTo(0) { inclusive = true } } }, - onProviderConfig = { - navController.navigate(Screen.ProviderConfig.route) - }, onVisualSettings = { navController.navigate(Screen.VisualSettings.route) }, @@ -141,12 +154,6 @@ fun NavGraph( ) } - composable(Screen.ProviderConfig.route) { - ProviderConfigScreen( - onNavigateBack = { navController.popBackStack() } - ) - } - composable(Screen.VisualSettings.route) { VisualSettingsScreen( onNavigateBack = { navController.popBackStack() } @@ -161,6 +168,9 @@ fun NavGraph( composable(Screen.ConnectionSettings.route) { dev.blazelight.p4oc.ui.screens.settings.ConnectionSettingsScreen( + viewModel = koinViewModel { + parametersOf(SettingsConnectionContext.Global) + }, onNavigateBack = { navController.popBackStack() } ) } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/navigation/Screen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/navigation/Screen.kt index 9d3d4d4b..cf81906f 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/navigation/Screen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/navigation/Screen.kt @@ -1,6 +1,8 @@ package dev.blazelight.p4oc.ui.navigation import android.net.Uri +import java.net.URLEncoder +import java.nio.charset.StandardCharsets sealed class Screen(val route: String) { data object Setup : Screen("setup") @@ -15,7 +17,7 @@ sealed class Screen(val route: String) { } data object Terminal : Screen("terminal/{ptyId}") { - fun createRoute(ptyId: String) = "terminal/$ptyId" + fun createRoute(ptyId: String) = "terminal/${ptyId.routeEncode()}" const val ARG_PTY_ID = "ptyId" } @@ -35,13 +37,14 @@ sealed class Screen(val route: String) { data object SessionDiff : Screen("session_diff/{sessionId}") { const val ARG_SESSION_ID = "sessionId" - fun createRoute(sessionId: String) = "session_diff/$sessionId" + fun createRoute(sessionId: String) = "session_diff/${sessionId.routeEncode()}" } data object ProviderConfig : Screen("settings/providers") data object Git : Screen("git?projectId={projectId}") { - fun createRoute(projectId: String? = null) = if (projectId != null) "git?projectId=$projectId" else "git" + fun createRoute(projectId: String? = null) = + if (projectId != null) "git?projectId=${projectId.routeEncode()}" else "git" const val ARG_PROJECT_ID = "projectId" } @@ -70,3 +73,7 @@ sealed class Screen(val route: String) { data object Projects : Screen("projects") } + +private fun String.routeEncode(): String = URLEncoder + .encode(this, StandardCharsets.UTF_8.name()) + .replace("+", "%20") diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatScreen.kt index 1e590952..e3fa8027 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatScreen.kt @@ -1,3 +1,5 @@ +@file:Suppress("ImportOrdering") + package dev.blazelight.p4oc.ui.screens.chat import androidx.activity.compose.BackHandler @@ -27,6 +29,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.blazelight.p4oc.R import dev.blazelight.p4oc.core.network.ConnectionState import dev.blazelight.p4oc.domain.model.Part +import dev.blazelight.p4oc.domain.model.MessageWithParts +import dev.blazelight.p4oc.domain.model.Permission import dev.blazelight.p4oc.domain.model.SessionConnectionState import dev.blazelight.p4oc.domain.model.SessionPresence import dev.blazelight.p4oc.ui.components.TuiConfirmDialog @@ -37,6 +41,7 @@ import dev.blazelight.p4oc.ui.components.TuiTopBar import dev.blazelight.p4oc.ui.components.chat.ChatInputBar import dev.blazelight.p4oc.ui.components.chat.FilePickerDialog import dev.blazelight.p4oc.ui.components.chat.JumpToBottomButton +import dev.blazelight.p4oc.ui.components.chat.InlinePermissionPrompt import dev.blazelight.p4oc.ui.components.chat.ModelAgentSelectorBar import dev.blazelight.p4oc.ui.components.command.CommandPalette import dev.blazelight.p4oc.ui.components.command.rememberResolvedCommandMetadata @@ -56,8 +61,47 @@ import org.koin.androidx.compose.koinViewModel internal fun pendingPermissionAttentionVersion(pendingPermissionCallIds: Set): String = pendingPermissionCallIds.sorted().joinToString(separator = "\u001F") +internal fun hasNewPendingPermission(previous: Set, current: Set): Boolean = + current.any { it !in previous } + +internal fun pendingPermissionBlockIndex( + blocks: List, + pendingCallIds: Set, +): Int? = blocks.indexOfFirst { block -> + val messages = when (block) { + is MessageBlock.UserBlock -> listOf(block.message) + is MessageBlock.AssistantBlock -> block.messages + } + messages.any { message -> + message.parts.any { part -> part is Part.Tool && part.callID in pendingCallIds } + } +}.takeIf { it >= 0 } + +/** Permissions with no live tool call are session-scoped and must not be attached to an arbitrary message. */ +internal fun unmatchedPendingPermissions( + messages: List, + pendingPermissionsByKey: Map, +): List { + val renderedToolCallIds = messages.asSequence() + .flatMap { it.parts.asSequence() } + .filterIsInstance() + .map { it.callID } + .toSet() + return pendingPermissionsByKey.values + .filter { permission -> permission.callID.isNullOrBlank() || permission.callID !in renderedToolCallIds } + .distinctBy(Permission::id) +} + +internal fun hasChatContent( + hasMessages: Boolean, + isBusy: Boolean, + hasPendingQuestion: Boolean, + hasSessionPendingPermissions: Boolean, +): Boolean = hasMessages || isBusy || hasPendingQuestion || hasSessionPendingPermissions + @OptIn(ExperimentalMaterial3Api::class) @Composable +@Suppress("LongParameterList", "LongMethod", "CyclomaticComplexMethod", "FunctionNaming") fun ChatScreen( viewModel: ChatViewModel = koinViewModel(), onNavigateBack: () -> Unit, @@ -65,6 +109,7 @@ fun ChatScreen( onOpenFiles: () -> Unit, onViewSessionDiff: ((String) -> Unit)? = null, onOpenSubSession: ((String) -> Unit)? = null, + onProviderAuthRequired: ((String) -> Unit)? = null, onSessionLoaded: ((sessionId: String, sessionTitle: String) -> Unit)? = null, onConnectionStateChanged: ((SessionConnectionState?) -> Unit)? = null, isActiveTab: Boolean = true @@ -80,6 +125,9 @@ fun ChatScreen( // Sub-manager state val pendingQuestion by viewModel.dialogManager.pendingQuestion.collectAsStateWithLifecycle() val pendingPermissionsByCallId by viewModel.dialogManager.pendingPermissionsByCallId.collectAsStateWithLifecycle() + val sessionPendingPermissions = remember(messages, pendingPermissionsByCallId) { + unmatchedPendingPermissions(messages, pendingPermissionsByCallId) + } val availableAgents by viewModel.modelAgentManager.availableAgents.collectAsStateWithLifecycle() val selectedAgent by viewModel.modelAgentManager.selectedAgent.collectAsStateWithLifecycle() val availableModels by viewModel.modelAgentManager.availableModels.collectAsStateWithLifecycle() @@ -203,6 +251,11 @@ fun ChatScreen( } ?: 0 val isBusy = uiState.isBusy val pendingQuestionId = pendingQuestion?.id + val pendingPermissionCallIds = pendingPermissionsByCallId.keys + val pendingPermissionVersion = pendingPermissionAttentionVersion(pendingPermissionCallIds) + var previouslyPendingPermissionCallIds by remember(uiState.session?.id) { + mutableStateOf(emptySet()) + } // Scroll on new messages, new parts, or streaming text/reasoning growth. LaunchedEffect(messageCount, tailContentVersion, isBusy, pendingQuestionId) { @@ -211,6 +264,29 @@ fun ChatScreen( } } + // Permissions can arrive for a tool rendered far above the current viewport without changing + // the message tail. Treat a newly pending call as explicit attention and reveal the approval UI. + LaunchedEffect(pendingPermissionVersion) { + val newPendingCallIds = pendingPermissionCallIds - previouslyPendingPermissionCallIds + val hasNewPermission = hasNewPendingPermission( + previous = previouslyPendingPermissionCallIds, + current = pendingPermissionCallIds, + ) + previouslyPendingPermissionCallIds = pendingPermissionCallIds.toSet() + if (hasNewPermission) { + val blockIndex = pendingPermissionBlockIndex(messageBlocks, newPendingCallIds) + if (blockIndex != null) { + // Keep streaming tail updates from immediately pulling the viewport away again. + scrollRestorationState.shouldFollowTail = false + val olderMessagesItemOffset = if (uiState.hasOlderMessages) 1 else 0 + listState.scrollToItem(blockIndex + olderMessagesItemOffset) + } else { + scrollRestorationState.onJumpToBottom() + listState.scrollChatToBottom() + } + } + } + // Keep the active hit in range when matches change, and scroll it into view. LaunchedEffect(searchMatches.size) { if (scrollRestorationState.currentMatchIndex >= searchMatches.size) { @@ -381,7 +457,12 @@ fun ChatScreen( } } - val hasContent = messages.isNotEmpty() || uiState.isBusy + val hasContent = hasChatContent( + hasMessages = messages.isNotEmpty(), + isBusy = uiState.isBusy, + hasPendingQuestion = pendingQuestion != null, + hasSessionPendingPermissions = sessionPendingPermissions.isNotEmpty(), + ) if (!hasContent && !uiState.isLoading) { EmptyChatView(modifier = Modifier.align(Alignment.Center)) @@ -392,6 +473,24 @@ fun ChatScreen( contentPadding = PaddingValues(vertical = Spacing.xxs, horizontal = Spacing.xs), verticalArrangement = Arrangement.spacedBy(Spacing.hairline), ) { + if (uiState.hasOlderMessages) { + item(key = "load_older_messages") { + TextButton( + onClick = viewModel::loadOlderMessages, + enabled = !uiState.isLoadingOlderMessages, + modifier = Modifier.fillMaxWidth(), + ) { + if (uiState.isLoadingOlderMessages) { + CircularProgressIndicator( + modifier = Modifier.size(Sizing.iconSm), + strokeWidth = Spacing.hairline, + ) + Spacer(Modifier.width(Spacing.xs)) + } + Text(stringResource(R.string.chat_load_older_messages)) + } + } + } // All messages - stable keys ensure only changed items recompose itemsIndexed( items = messageBlocks, @@ -417,6 +516,7 @@ fun ChatScreen( onToolDeny = { viewModel.respondToPermission(it, "reject") }, onToolAlways = { viewModel.respondToPermission(it, "always") }, onOpenSubSession = onOpenSubSession, + onProviderAuthRequired = onProviderAuthRequired, defaultToolWidgetState = defaultToolWidgetState, pendingPermissionsByCallId = pendingPermissionsByCallId, onRevert = { messageId -> showRevertDialog = messageId } @@ -424,6 +524,19 @@ fun ChatScreen( } } + itemsIndexed( + items = sessionPendingPermissions, + key = { _, permission -> "pending_permission_${permission.id}" }, + ) { _, permission -> + InlinePermissionPrompt( + permission = permission, + onAllow = { viewModel.respondToPermission(permission.id, "once") }, + onAlways = { viewModel.respondToPermission(permission.id, "always") }, + onReject = { viewModel.respondToPermission(permission.id, "reject") }, + modifier = Modifier.padding(vertical = Spacing.xs), + ) + } + pendingQuestion?.let { questionRequest -> item(key = "pending_question_${questionRequest.id}") { InlineQuestionCard( diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModel.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModel.kt index baa33bfb..af3455ad 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModel.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModel.kt @@ -10,8 +10,8 @@ import dev.blazelight.p4oc.core.haptic.HapticFeedback import dev.blazelight.p4oc.core.log.AppLog import dev.blazelight.p4oc.core.mime.FilenameMimeType import dev.blazelight.p4oc.core.network.ApiResult -import dev.blazelight.p4oc.core.network.ConnectionManager import dev.blazelight.p4oc.core.network.ConnectionState +import dev.blazelight.p4oc.core.network.ServerConnectionRegistry import dev.blazelight.p4oc.core.network.safeApiCall import dev.blazelight.p4oc.data.remote.dto.ExecuteCommandRequest import dev.blazelight.p4oc.data.remote.dto.PartInputDto @@ -31,6 +31,7 @@ import dev.blazelight.p4oc.domain.session.SessionId import dev.blazelight.p4oc.ui.components.chat.SelectedFile import dev.blazelight.p4oc.ui.navigation.Screen import dev.blazelight.p4oc.ui.screens.files.upload.UploadCoordinator +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import kotlinx.serialization.SerializationException @@ -43,18 +44,20 @@ import java.net.URI * dialogs, model/agent selection, and file picking. Retains session * lifecycle, message sending, command execution, and SSE event routing. */ +@Suppress("LargeClass", "LongParameterList") class ChatViewModel constructor( private val savedStateHandle: SavedStateHandle, private val workspaceClient: WorkspaceClient, private val sessionRepository: SessionRepositoryImpl, private val uploadCoordinator: UploadCoordinator, - private val connectionManager: ConnectionManager, private val settingsDataStore: SettingsDataStore, private val hapticFeedback: HapticFeedback, - private val modelSelectionCoordinator: ModelSelectionCoordinator = ModelSelectionCoordinator() + private val modelSelectionCoordinator: ModelSelectionCoordinator = ModelSelectionCoordinator(), + private val serverConnectionRegistry: ServerConnectionRegistry? = null, ) : ViewModel() { private val sessionId: String = savedStateHandle.get(Screen.Chat.ARG_SESSION_ID) ?: throw IllegalArgumentException("sessionId is required for ChatViewModel") + private val sessionLease = sessionRepository.acquireSession(SessionId(sessionId)) // JSON serializer for SavedStateHandle persistence private val json = Json { ignoreUnknownKeys = true } @@ -62,11 +65,12 @@ class ChatViewModel constructor( // --- Sub-managers --- val dialogManager = DialogQueueManager(savedStateHandle, json, viewModelScope) val modelAgentManager = ModelAgentManager( - connectionManager, + workspaceClient, settingsDataStore, viewModelScope, sessionId, - modelSelectionCoordinator + modelSelectionCoordinator, + serverConnectionRegistry, ) val filePickerManager = FilePickerManager(workspaceClient, viewModelScope, uploadCoordinator, settingsDataStore) @@ -81,7 +85,7 @@ class ChatViewModel constructor( private val repositorySessionState: StateFlow = sessionRepository.sessionUiState(SessionId(sessionId)) - val connectionState: StateFlow = connectionManager.connectionState + val connectionState: StateFlow = workspaceClient.connectionState private val _branchName = MutableStateFlow(null) val branchName: StateFlow = _branchName.asStateFlow() @@ -91,6 +95,28 @@ class ChatViewModel constructor( val hasUnreadResponse: StateFlow = _hasUnreadResponse.asStateFlow() private val _isActiveTab = MutableStateFlow(false) + init { + serverConnectionRegistry?.let(::observeCommandCatalogEvents) + } + + @OptIn(FlowPreview::class) + private fun observeCommandCatalogEvents(registry: ServerConnectionRegistry) { + viewModelScope.launch { + registry.events(workspaceClient.workspace.server) + .filter { scopedEvent -> + val event = scopedEvent.event + val refreshesCommands = event is OpenCodeEvent.ModelsRefreshed || + event is OpenCodeEvent.CatalogUpdated || + event is OpenCodeEvent.McpToolsChanged + scopedEvent.generation == workspaceClient.generation && + scopedEvent.workspaceKey == workspaceClient.workspace.key && + refreshesCommands + } + .debounce(COMMAND_CATALOG_REFRESH_DEBOUNCE_MS) + .collect { refreshCommandsInBackground() } + } + } + /** * UI presence for tab indicators. Awaiting input is reserved for real * permission/question prompts; unread responses are a separate state. @@ -143,8 +169,15 @@ class ChatViewModel constructor( private companion object { const val TAG = "ChatViewModel" + private const val INITIAL_HISTORY_LIMIT = 100 + private const val HISTORY_PAGE_SIZE = 100 private const val KEY_DRAFT_TEXT = "chat_draft_text" private const val KEY_ATTACHED_FILES = "chat_attached_files" + private const val COMMAND_CATALOG_REFRESH_DEBOUNCE_MS = 150L + + // SavedState shares Android's Binder transaction budget with the rest of the Activity. + private const val MAX_PERSISTED_DRAFT_CHARS = 64 * 1024 + private const val MAX_PERSISTED_ATTACHMENTS_JSON_CHARS = 64 * 1024 private const val UNAVAILABLE_ATTACHMENTS_ERROR = "Remove unavailable attachments before sending." @@ -173,18 +206,18 @@ class ChatViewModel constructor( return try { json.decodeFromString>(jsonString) } catch (e: SerializationException) { - AppLog.e(TAG, "Failed to restore attached files", e) + AppLog.e(TAG, "Failed to restore attached files") savedStateHandle.remove(KEY_ATTACHED_FILES) emptyList() } catch (e: IllegalArgumentException) { - AppLog.e(TAG, "Failed to restore attached files", e) + AppLog.e(TAG, "Failed to restore attached files") savedStateHandle.remove(KEY_ATTACHED_FILES) emptyList() } } private fun persistInputText(text: String) { - if (text.isEmpty()) { + if (text.isEmpty() || text.length > MAX_PERSISTED_DRAFT_CHARS) { savedStateHandle.remove(KEY_DRAFT_TEXT) } else { savedStateHandle[KEY_DRAFT_TEXT] = text @@ -195,7 +228,12 @@ class ChatViewModel constructor( if (files.isEmpty()) { savedStateHandle.remove(KEY_ATTACHED_FILES) } else { - savedStateHandle[KEY_ATTACHED_FILES] = json.encodeToString(files) + val encoded = json.encodeToString(files) + if (encoded.length <= MAX_PERSISTED_ATTACHMENTS_JSON_CHARS) { + savedStateHandle[KEY_ATTACHED_FILES] = encoded + } else { + savedStateHandle.remove(KEY_ATTACHED_FILES) + } } } @@ -273,18 +311,26 @@ class ChatViewModel constructor( viewModelScope.launch { _uiState.update { it.copy(isLoading = true) } beginLoadStep("Loading session messages") - AppLog.d(TAG, "loadMessages() called for session: $sessionId") + AppLog.d(TAG, "loadMessages() called") - val result = safeApiCall { sessionRepository.loadMessages(SessionId(sessionId), limit = null) } + val result = safeApiCall { + sessionRepository.loadMessages(SessionId(sessionId), limit = INITIAL_HISTORY_LIMIT) + } endLoadStep("Loading session messages") when (result) { is ApiResult.Success -> { AppLog.d(TAG, "Loaded ${messages.value.size} messages") - _uiState.update { it.copy(isLoading = false) } + _uiState.update { + it.copy( + isLoading = false, + historyLimit = INITIAL_HISTORY_LIMIT, + hasOlderMessages = result.data >= INITIAL_HISTORY_LIMIT, + ) + } } is ApiResult.Error -> { - AppLog.e(TAG, "Failed to load messages: ${result.message}", result.throwable) + AppLog.e(TAG, "Failed to load messages") if (result.code == 404) { _sessionMissing.emit(Unit) } else { @@ -297,12 +343,44 @@ class ChatViewModel constructor( } } + fun loadOlderMessages() { + val current = _uiState.value + if (current.isLoading || current.isLoadingOlderMessages || !current.hasOlderMessages) return + + val nextLimit = current.historyLimit + HISTORY_PAGE_SIZE + _uiState.update { it.copy(isLoadingOlderMessages = true) } + viewModelScope.launch { + when ( + val result = safeApiCall { + sessionRepository.loadMessages(SessionId(sessionId), limit = nextLimit) + } + ) { + is ApiResult.Success -> _uiState.update { + it.copy( + isLoadingOlderMessages = false, + historyLimit = nextLimit, + hasOlderMessages = result.data >= nextLimit, + ) + } + is ApiResult.Error -> { + AppLog.e(TAG, "Failed to load older messages") + _uiState.update { + it.copy( + isLoadingOlderMessages = false, + error = "Failed to load older messages", + ) + } + } + } + } + } + private fun loadVcsInfo() { viewModelScope.launch { beginLoadStep("Loading workspace status") when (val result = safeApiCall { workspaceClient.getVcsInfo() }) { is ApiResult.Success -> _branchName.value = result.data.branch - is ApiResult.Error -> AppLog.w(TAG, "Failed to load VCS info: ${result.message}") + is ApiResult.Error -> AppLog.w(TAG, "Failed to load VCS info") } endLoadStep("Loading workspace status") } @@ -396,7 +474,7 @@ class ChatViewModel constructor( _uiState.update { it.copy( isSending = false, - error = "Failed to send: ${result.message}" + error = "Could not send the message. Check the connection and try again." ) } updateInput(text) @@ -455,7 +533,7 @@ class ChatViewModel constructor( sessionRepository.clearPermission(SessionId(sessionId), permissionId) it } else { - it.copy(error = "Failed to respond to permission: ${result.message}") + it.copy(error = "Could not respond to the permission request. Try again.") } } } @@ -465,11 +543,11 @@ class ChatViewModel constructor( fun respondToQuestion(requestId: String, answers: List>) { viewModelScope.launch { val request = QuestionReplyRequest(answers = answers) - when (val result = safeApiCall { workspaceClient.respondToQuestion(requestId, request) }) { + when (val result = safeApiCall { workspaceClient.respondToQuestion(sessionId, requestId, request) }) { is ApiResult.Success -> sessionRepository.clearQuestion(SessionId(sessionId), requestId) is ApiResult.Error -> _uiState.update { it.copy( - error = "Failed to answer question: ${result.message}" + error = "Could not answer the question. Try again." ) } } @@ -483,13 +561,13 @@ class ChatViewModel constructor( // goes idle). The local modal is cleared optimistically; the matching // question.rejected SSE event (handled in SessionRepositoryImpl) will // also reconcile any other attached client. - when (val result = safeApiCall { workspaceClient.rejectQuestion(requestId) }) { + when (val result = safeApiCall { workspaceClient.rejectQuestion(sessionId, requestId) }) { is ApiResult.Success -> sessionRepository.clearQuestion(SessionId(sessionId), requestId) is ApiResult.Error -> { // A NotFound here means it was already resolved elsewhere — clear // locally anyway so the user is not stuck on a dead modal. sessionRepository.clearQuestion(SessionId(sessionId), requestId) - AppLog.w(TAG, "rejectQuestion failed (clearing locally): ${result.message}") + AppLog.w(TAG, "Question rejection failed; clearing resolved prompt locally") } } } @@ -524,16 +602,33 @@ class ChatViewModel constructor( } } is ApiResult.Error -> { - AppLog.e(TAG, "loadCommands failed: ${result.message}", result.throwable) + AppLog.e(TAG, "loadCommands failed") _uiState.update { it.copy( commands = it.commands.ifEmpty { BUILTIN_COMMANDS }, isLoadingCommands = false, hasLoadedWorkspaceCommands = false, - commandLoadError = result.message.ifBlank { "Unable to load workspace commands" } + commandLoadError = "Could not load workspace commands. Try again." + ) + } + } + } + } + } + + private fun refreshCommandsInBackground() { + viewModelScope.launch { + when (val result = safeApiCall { workspaceClient.listCommands() }) { + is ApiResult.Success -> { + val apiCommands = result.data.map(CommandMapper::mapToDomain) + _uiState.update { + it.copy( + commands = (BUILTIN_COMMANDS + apiCommands).distinctBy(Command::name), + hasLoadedWorkspaceCommands = true, ) } } + is ApiResult.Error -> AppLog.d(TAG, "Background command refresh failed") } } } @@ -568,7 +663,7 @@ class ChatViewModel constructor( } is ApiResult.Error -> { _uiState.update { - it.copy(isSending = false, error = "Failed to execute command: ${result.message}") + it.copy(isSending = false, error = "Could not execute the command. Try again.") } } } @@ -645,7 +740,7 @@ class ChatViewModel constructor( loadSession() } is ApiResult.Error -> { - _uiState.update { it.copy(isSending = false, error = "Failed to $action: ${result.message}") } + _uiState.update { it.copy(isSending = false, error = "Could not $action. Try again.") } } } } @@ -660,7 +755,7 @@ class ChatViewModel constructor( loadSession() // Refresh to get updated revert state } is ApiResult.Error -> { - _uiState.update { it.copy(error = "Failed to revert: ${result.message}") } + _uiState.update { it.copy(error = "Could not revert the session. Try again.") } } } } @@ -674,7 +769,7 @@ class ChatViewModel constructor( loadSession() // Refresh to clear revert state } is ApiResult.Error -> { - _uiState.update { it.copy(error = "Failed to unrevert: ${result.message}") } + _uiState.update { it.copy(error = "Could not restore the session. Try again.") } } } } @@ -690,20 +785,22 @@ class ChatViewModel constructor( _uiState.update { it.copy(isBusy = false, isSending = false) } } is ApiResult.Error -> _uiState.update { - it.copy(error = "Failed to stop run: ${result.message.toHumanAbortError()}") + it.copy(error = "Could not stop the run. Try again.") } } } } - private fun String.toHumanAbortError(): String { - val trimmed = trim() - if (trimmed.startsWith("{") || trimmed.startsWith("[")) return "Unable to stop run" - return trimmed.ifBlank { "Unable to stop run" } + override fun onCleared() { + sessionLease.close() + super.onCleared() } - private fun dev.blazelight.p4oc.domain.model.MessageError.toHumanMessage(): String = - message?.toHumanAbortError() ?: "An error occurred" + private fun dev.blazelight.p4oc.domain.model.MessageError.toHumanMessage(): String = when { + name == "ProviderAuthError" -> "Provider authentication required" + isRetryable -> "The request failed temporarily. Try again." + else -> "The run failed. Try again." + } private fun Result.toApiResult(): ApiResult = fold( onSuccess = { ApiResult.Success(it) }, @@ -719,6 +816,9 @@ data class ChatUiState( val session: Session? = null, val inputText: String = "", val isLoading: Boolean = false, + val isLoadingOlderMessages: Boolean = false, + val hasOlderMessages: Boolean = false, + val historyLimit: Int = 0, val loadingSteps: Set = emptySet(), val isSending: Boolean = false, val isBusy: Boolean = false, diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/DialogQueueManager.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/DialogQueueManager.kt index 0ec85035..3a2ba27a 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/DialogQueueManager.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/DialogQueueManager.kt @@ -50,9 +50,9 @@ class DialogQueueManager( try { val question = json.decodeFromString(jsonString) _pendingQuestion.value = question - AppLog.d(TAG, "Restored pending question: ${question.id}") + AppLog.d(TAG, "Restored pending question") } catch (e: Exception) { - AppLog.e(TAG, "Failed to restore pending question", e) + AppLog.e(TAG, "Failed to restore pending question (${e::class.simpleName})") savedStateHandle.remove(KEY_PENDING_QUESTION) } } @@ -64,7 +64,7 @@ class DialogQueueManager( pendingQuestions.addAll(questions) AppLog.d(TAG, "Restored ${questions.size} queued questions") } catch (e: Exception) { - AppLog.e(TAG, "Failed to restore pending questions queue", e) + AppLog.e(TAG, "Failed to restore pending questions queue (${e::class.simpleName})") savedStateHandle.remove(KEY_PENDING_QUESTIONS_QUEUE) } } @@ -133,14 +133,18 @@ class DialogQueueManager( } } catch (e: Exception) { if (e is CancellationException) throw e - AppLog.e(TAG, "Failed to persist pending question", e) + AppLog.e(TAG, "Failed to persist pending question (${e::class.simpleName})") if (version == pendingQuestionPersistenceVersion) { savedStateHandle.remove(KEY_PENDING_QUESTION) } return@launch } if (version == pendingQuestionPersistenceVersion && _pendingQuestion.value == request) { - savedStateHandle[KEY_PENDING_QUESTION] = encoded + persistBounded( + key = KEY_PENDING_QUESTION, + encoded = encoded, + otherKey = KEY_PENDING_QUESTIONS_QUEUE, + ) } } } @@ -161,7 +165,7 @@ class DialogQueueManager( } } catch (e: Exception) { if (e is CancellationException) throw e - AppLog.e(TAG, "Failed to persist pending questions queue", e) + AppLog.e(TAG, "Failed to persist pending questions queue (${e::class.simpleName})") if (version == pendingQuestionsQueuePersistenceVersion) { savedStateHandle.remove(KEY_PENDING_QUESTIONS_QUEUE) } @@ -171,15 +175,38 @@ class DialogQueueManager( if (encoded == null) { savedStateHandle.remove(KEY_PENDING_QUESTIONS_QUEUE) } else { - savedStateHandle[KEY_PENDING_QUESTIONS_QUEUE] = encoded + persistBounded( + key = KEY_PENDING_QUESTIONS_QUEUE, + encoded = encoded, + otherKey = KEY_PENDING_QUESTION, + ) } } } } + /** Writes atomically with the combined-size check so concurrent persistence cannot exceed the budget. */ + private fun persistBounded(key: String, encoded: String, otherKey: String) { + synchronized(savedStateHandle) { + val otherSize = savedStateHandle.get(otherKey)?.length ?: 0 + if ( + encoded.length <= MAX_PERSISTED_QUESTION_JSON_CHARS && + encoded.length + otherSize <= MAX_PERSISTED_QUESTIONS_JSON_CHARS + ) { + savedStateHandle[key] = encoded + } else { + savedStateHandle.remove(key) + } + } + } + private companion object { const val TAG = "DialogQueueManager" const val KEY_PENDING_QUESTION = "pending_question" const val KEY_PENDING_QUESTIONS_QUEUE = "pending_questions_queue" + + // Leave ample room in the Activity SavedState bundle for navigation and other screens. + const val MAX_PERSISTED_QUESTION_JSON_CHARS = 64 * 1024 + const val MAX_PERSISTED_QUESTIONS_JSON_CHARS = 96 * 1024 } } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/FilePickerManager.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/FilePickerManager.kt index 1527523f..4b52ec7d 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/FilePickerManager.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/FilePickerManager.kt @@ -57,7 +57,7 @@ class FilePickerManager( val effectivePath = path ?: rememberedPath?.ifBlank { null } ?: "." val result = loadPickerFilesForPath(workspaceKey, effectivePath) if (result is ApiResult.Error && path == null && effectivePath != ".") { - AppLog.w(TAG, "Remembered upload folder '$effectivePath' unavailable; falling back to root") + AppLog.w(TAG, "Remembered upload folder unavailable; falling back to root") settingsDataStore.setLastUploadDirectory(workspaceKey, null) loadPickerFilesForPath(workspaceKey, ".") } @@ -86,8 +86,8 @@ class FilePickerManager( return ApiResult.Success(Unit) } is ApiResult.Error -> { - AppLog.w(TAG, "Failed to load files for path=$path: ${result.message}") - _pickerError.value = result.message + AppLog.w(TAG, "Failed to load files") + _pickerError.value = "Could not load files. Check the connection and try again." _isPickerLoading.value = false return result } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/MessageBlockUtils.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/MessageBlockUtils.kt index 94a62e93..9343150f 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/MessageBlockUtils.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/MessageBlockUtils.kt @@ -93,12 +93,14 @@ private fun revertTargetsByUserId(messages: List): Map Unit, onToolDeny: (String) -> Unit, onToolAlways: (String) -> Unit, onOpenSubSession: ((String) -> Unit)? = null, + onProviderAuthRequired: ((String) -> Unit)? = null, defaultToolWidgetState: ToolWidgetState = ToolWidgetState.COMPACT, pendingPermissionsByCallId: Map = emptyMap(), onRevert: ((String) -> Unit)? = null @@ -111,6 +113,7 @@ internal fun MessageBlockView( onToolDeny = onToolDeny, onToolAlways = onToolAlways, onOpenSubSession = onOpenSubSession, + onProviderAuthRequired = onProviderAuthRequired, defaultToolWidgetState = defaultToolWidgetState, pendingPermissionsByCallId = pendingPermissionsByCallId, onRevert = block.revertMessageId?.let { messageId -> @@ -126,6 +129,7 @@ internal fun MessageBlockView( onToolDeny = onToolDeny, onToolAlways = onToolAlways, onOpenSubSession = onOpenSubSession, + onProviderAuthRequired = onProviderAuthRequired, defaultToolWidgetState = defaultToolWidgetState, pendingPermissionsByCallId = pendingPermissionsByCallId, ) diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ModelAgentManager.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ModelAgentManager.kt index 35ffda56..cc740552 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ModelAgentManager.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/chat/ModelAgentManager.kt @@ -3,17 +3,22 @@ package dev.blazelight.p4oc.ui.screens.chat import dev.blazelight.p4oc.core.datastore.SettingsDataStore import dev.blazelight.p4oc.core.log.AppLog import dev.blazelight.p4oc.core.network.ApiResult -import dev.blazelight.p4oc.core.network.ConnectionManager +import dev.blazelight.p4oc.core.network.ServerConnectionRegistry import dev.blazelight.p4oc.core.network.safeApiCall import dev.blazelight.p4oc.data.remote.dto.AgentDto import dev.blazelight.p4oc.data.remote.dto.ModelDto import dev.blazelight.p4oc.data.remote.dto.ModelInput import dev.blazelight.p4oc.data.remote.dto.reasoningEfforts +import dev.blazelight.p4oc.data.workspace.WorkspaceClient +import dev.blazelight.p4oc.domain.model.OpenCodeEvent import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -21,11 +26,12 @@ import kotlinx.coroutines.launch * Manages model/agent loading, selection, favorites, and recents. */ class ModelAgentManager( - private val connectionManager: ConnectionManager, + private val workspaceClient: WorkspaceClient, private val settingsDataStore: SettingsDataStore, private val scope: CoroutineScope, private val sessionId: String? = null, - modelSelectionCoordinator: ModelSelectionCoordinator? = null + modelSelectionCoordinator: ModelSelectionCoordinator? = null, + serverConnectionRegistry: ServerConnectionRegistry? = null, ) { private val _availableAgents = MutableStateFlow>(emptyList()) val availableAgents: StateFlow> = _availableAgents.asStateFlow() @@ -59,22 +65,40 @@ class ModelAgentManager( } } } + serverConnectionRegistry?.let(::observeCatalogEvents) + } + + @OptIn(FlowPreview::class) + private fun observeCatalogEvents(registry: ServerConnectionRegistry) { + scope.launch { + registry.events(workspaceClient.workspace.server) + .filter { scopedEvent -> + val event = scopedEvent.event + val refreshesCatalog = event is OpenCodeEvent.ModelsRefreshed || + event is OpenCodeEvent.CatalogUpdated || + event is OpenCodeEvent.McpToolsChanged + scopedEvent.generation == workspaceClient.generation && + scopedEvent.workspaceKey == workspaceClient.workspace.key && + refreshesCatalog + } + .debounce(EVENT_REFRESH_DEBOUNCE_MS) + .collect { event -> + if (event.event !is OpenCodeEvent.McpToolsChanged) loadModels() + loadAgents() + } + } } fun loadAgents() { scope.launch { - val api = connectionManager.getApi() ?: run { - AppLog.d(TAG, "loadAgents: No API available") - return@launch - } - val result = safeApiCall { api.getAgents() } + val result = safeApiCall { workspaceClient.getAgents() } when (result) { is ApiResult.Success -> { AppLog.d(TAG, "loadAgents: Got ${result.data.size} agents") val primaryAgents = result.data.filter { - it.mode == "primary" && it.hidden != true + it.mode in PRIMARY_COMPOSER_MODES && it.hidden != true } - AppLog.d(TAG, "loadAgents: ${primaryAgents.size} primary agents: ${primaryAgents.map { it.name }}") + AppLog.d(TAG, "loadAgents: ${primaryAgents.size} primary agents") _availableAgents.value = primaryAgents val persistedAgent = sessionId?.let { settingsDataStore.getSelectedAgentForSession(it) } val selectedAgent = persistedAgent?.let { agentName -> @@ -83,7 +107,7 @@ class ModelAgentManager( selectedAgent?.name?.let { selectAgent(it, persist = false) } } is ApiResult.Error -> { - AppLog.e(TAG, "loadAgents failed: ${result.message}") + AppLog.e(TAG, "loadAgents failed") } } } @@ -113,8 +137,7 @@ class ModelAgentManager( fun loadModels() { scope.launch { - val api = connectionManager.getApi() ?: return@launch - val result = safeApiCall { api.getProviders() } + val result = safeApiCall { workspaceClient.getProviders() } when (result) { is ApiResult.Success -> { val models = mutableListOf>() @@ -196,5 +219,7 @@ class ModelAgentManager( private companion object { const val TAG = "ModelAgentManager" + val PRIMARY_COMPOSER_MODES = setOf("primary", "all") + const val EVENT_REFRESH_DEBOUNCE_MS = 150L } } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/diff/SessionDiffScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/diff/SessionDiffScreen.kt index 2f4bdc87..c6e69af1 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/diff/SessionDiffScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/diff/SessionDiffScreen.kt @@ -29,7 +29,7 @@ import androidx.compose.ui.text.font.FontFamily import dev.blazelight.p4oc.R import dev.blazelight.p4oc.core.network.ApiResult import dev.blazelight.p4oc.core.network.safeApiCall -import dev.blazelight.p4oc.data.remote.dto.FileDiffDto +import dev.blazelight.p4oc.data.remote.dto.SnapshotFileDiffDto import dev.blazelight.p4oc.data.workspace.WorkspaceClient import dev.blazelight.p4oc.ui.components.TuiEmptyState import dev.blazelight.p4oc.ui.components.TuiLoadingScreen @@ -46,7 +46,7 @@ fun SessionDiffScreen( onNavigateBack: () -> Unit, ) { val theme = LocalOpenCodeTheme.current - var diffs by remember { mutableStateOf?>(null) } + var diffs by remember { mutableStateOf?>(null) } var isLoading by remember { mutableStateOf(true) } var errorMessage by remember { mutableStateOf(null) } @@ -60,7 +60,7 @@ fun SessionDiffScreen( } ) { is ApiResult.Success -> diffs = result.data - is ApiResult.Error -> errorMessage = result.message + is ApiResult.Error -> errorMessage = "failed" } isLoading = false } @@ -91,7 +91,7 @@ fun SessionDiffScreen( ) { TuiEmptyState( icon = Icons.Default.ErrorOutline, - title = errorMessage.orEmpty() + title = stringResource(R.string.session_diff_load_failed) ) } } @@ -112,8 +112,8 @@ fun SessionDiffScreen( else -> { val fileList = diffs.orEmpty() - val totalAdditions = fileList.sumOf { it.additions } - val totalDeletions = fileList.sumOf { it.deletions } + val totalAdditions = fileList.sumOf { it.additions }.toInt() + val totalDeletions = fileList.sumOf { it.deletions }.toInt() LazyColumn( modifier = Modifier @@ -140,19 +140,13 @@ fun SessionDiffScreen( } } - items(fileList, key = { it.file }) { fileDiff -> - val diffContent = remember(fileDiff.file, fileDiff.before, fileDiff.after) { - dev.blazelight.p4oc.ui.diff.UnifiedDiffBuilder.build( - filePath = fileDiff.file, - before = fileDiff.before, - after = fileDiff.after - ) - } + items(fileList) { fileDiff -> + val fileName = fileDiff.file ?: stringResource(R.string.session_diff_unknown_file) InlineDiffViewer( - fileName = fileDiff.file, - diffContent = diffContent, - additions = fileDiff.additions, - deletions = fileDiff.deletions + fileName = fileName, + diffContent = fileDiff.patch.orEmpty(), + additions = fileDiff.additions.toInt(), + deletions = fileDiff.deletions.toInt() ) } } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/files/FileExplorerScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/files/FileExplorerScreen.kt index 08d75620..d6521808 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/files/FileExplorerScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/files/FileExplorerScreen.kt @@ -28,7 +28,16 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.LiveRegionMode import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight @@ -264,10 +273,7 @@ fun FileExplorerScreen( shape = RectangleShape, ) { Text( - text = stringResource( - R.string.files_restored_path_unavailable, - uiState.pathRestoreError.orEmpty(), - ), + text = stringResource(R.string.files_restored_path_unavailable), color = theme.error, modifier = Modifier.padding(Spacing.sm), ) @@ -304,7 +310,7 @@ fun FileExplorerScreen( color = theme.error ) Text( - text = stringResource(R.string.files_symbol_search_failed, uiState.symbolError.orEmpty()), + text = stringResource(R.string.files_symbol_search_failed), color = theme.error ) } @@ -345,6 +351,39 @@ fun FileExplorerScreen( uiState.isLoading -> { TuiLoadingScreen(modifier = Modifier.align(Alignment.Center)) } + uiState.error != null && uiState.files.isEmpty() -> { + Column( + modifier = Modifier + .align(Alignment.Center) + .padding(Spacing.lg) + .testTag("files_load_error"), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(Spacing.md), + ) { + Icon( + imageVector = Icons.Default.ErrorOutline, + contentDescription = null, + tint = theme.error, + modifier = Modifier.size(Sizing.iconLg), + ) + Text( + text = stringResource(R.string.files_load_failed), + style = MaterialTheme.typography.titleMedium, + color = theme.error, + ) + Text( + text = stringResource(R.string.files_load_failed_hint), + style = MaterialTheme.typography.bodyMedium, + color = theme.textMuted, + ) + TuiButton( + onClick = viewModel::refresh, + modifier = Modifier.testTag("files_load_retry"), + ) { + Text(stringResource(R.string.retry)) + } + } + } filteredFiles.isEmpty() -> { Column( modifier = Modifier.align(Alignment.Center), @@ -373,28 +412,55 @@ fun FileExplorerScreen( } } else -> { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(Spacing.md), - verticalArrangement = Arrangement.spacedBy(Spacing.xxs) - ) { - items(filteredFiles, key = { it.path }) { file -> - TuiFileItem( - file = file, - actions = FileItemActions( - canRename = uiState.capabilities.canRename, - canDelete = uiState.capabilities.canDelete, - onRename = { renameTarget = file }, - onDelete = { deleteTarget = file }, - ), - onClick = { - if (file.isDirectory) { - viewModel.navigateTo(file.path) - } else { - onFileClick(file.path) + Column(Modifier.fillMaxSize()) { + if (uiState.error != null) { + Surface( + color = theme.error.copy(alpha = 0.12f), + shape = RectangleShape, + modifier = Modifier + .fillMaxWidth() + .semantics { liveRegion = LiveRegionMode.Polite } + .testTag("files_refresh_error"), + ) { + Row( + modifier = Modifier.padding(horizontal = Spacing.md, vertical = Spacing.sm), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.sm), + ) { + Text( + text = stringResource(R.string.files_refresh_failed), + color = theme.error, + modifier = Modifier.weight(1f), + ) + TuiTextButton(onClick = viewModel::refresh) { + Text(stringResource(R.string.retry)) } - }, - ) + } + } + } + LazyColumn( + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(Spacing.md), + verticalArrangement = Arrangement.spacedBy(Spacing.xxs) + ) { + items(filteredFiles, key = { it.path }) { file -> + TuiFileItem( + file = file, + actions = FileItemActions( + canRename = uiState.capabilities.canRename, + canDelete = uiState.capabilities.canDelete, + onRename = { renameTarget = file }, + onDelete = { deleteTarget = file }, + ), + onClick = { + if (file.isDirectory) { + viewModel.navigateTo(file.path) + } else { + onFileClick(file.path) + } + }, + ) + } } } } @@ -458,7 +524,7 @@ fun FileExplorerScreen( ) } - uiState.mutationMessage?.let { message -> + uiState.mutationMessage?.let { TuiAlertDialog( onDismissRequest = viewModel::clearMutationMessage, title = stringResource(R.string.files_operation_failed), @@ -468,7 +534,7 @@ fun FileExplorerScreen( } }, ) { - Text(message, color = theme.textMuted) + Text(stringResource(R.string.files_operation_failed_hint), color = theme.textMuted) } } } @@ -548,12 +614,14 @@ private fun EmptyFolderActions( } } +@Suppress("FunctionNaming", "LongMethod") @Composable -private fun BreadcrumbNavigation( +internal fun BreadcrumbNavigation( path: String, onNavigateTo: (String) -> Unit ) { val theme = LocalOpenCodeTheme.current + val rootDescription = stringResource(R.string.files_breadcrumb_root) val parts = path.split("/").filter { it.isNotEmpty() } Row( @@ -568,6 +636,13 @@ private fun BreadcrumbNavigation( // Root indicator Surface( onClick = { onNavigateTo("") }, + modifier = Modifier + .sizeIn( + minWidth = Sizing.minTouchTarget, + minHeight = Sizing.minTouchTarget, + ) + .semantics { contentDescription = rootDescription } + .testTag("files_breadcrumb_root"), color = Color.Transparent, shape = RectangleShape ) { @@ -592,6 +667,12 @@ private fun BreadcrumbNavigation( Surface( onClick = { onNavigateTo(pathToNavigate) }, + modifier = Modifier + .sizeIn( + minWidth = Sizing.minTouchTarget, + minHeight = Sizing.minTouchTarget, + ) + .testTag("files_breadcrumb_segment_$index"), color = Color.Transparent, shape = RectangleShape ) { @@ -622,6 +703,43 @@ private fun TuiFileItem( val clipboardManager = LocalClipboardManager.current val haptic = LocalHapticFeedback.current var showContextMenu by remember { mutableStateOf(false) } + val itemType = stringResource( + if (file.isDirectory) R.string.files_type_folder else R.string.files_type_file, + ) + val itemDescription = file.gitStatus?.let { status -> + stringResource( + R.string.files_item_description_git, + file.name, + itemType, + file.path, + status, + ) + } ?: stringResource( + R.string.files_item_description, + file.name, + itemType, + file.path, + ) + val renameLabel = stringResource(R.string.files_rename) + val deleteLabel = stringResource(R.string.files_delete) + val accessibilityActions = buildList { + if (actions.canRename) { + add( + CustomAccessibilityAction(renameLabel) { + actions.onRename() + true + } + ) + } + if (actions.canDelete) { + add( + CustomAccessibilityAction(deleteLabel) { + actions.onDelete() + true + } + ) + } + } Box { Surface( @@ -634,7 +752,16 @@ private fun TuiFileItem( showContextMenu = true }, role = Role.Button - ), + ) + .clearAndSetSemantics { + role = Role.Button + contentDescription = itemDescription + onClick { + onClick() + true + } + customActions = accessibilityActions + }, color = Color.Transparent, shape = RectangleShape ) { @@ -655,7 +782,7 @@ private fun TuiFileItem( // Icon Icon( imageVector = icon, - contentDescription = if (file.isDirectory) "Folder" else "File", + contentDescription = null, tint = gitStatusColor ?: iconColor, modifier = Modifier.size(Sizing.iconSm) ) diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/files/FileViewerScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/files/FileViewerScreen.kt index d282dba4..7b05238b 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/files/FileViewerScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/files/FileViewerScreen.kt @@ -55,6 +55,13 @@ fun FileViewerScreen( LaunchedEffect(path) { viewModel.loadFileContent(path) } + LaunchedEffect(uiState.capabilitiesLoaded, uiState.capabilities.canWrite) { + if (uiState.capabilitiesLoaded && !uiState.capabilities.canWrite && editMode) { + editMode = false + pendingDiscard = null + viewModel.discardEdits() + } + } val filename = path.substringAfterLast("/") val languageLabelRes = remember(filename) { @@ -111,7 +118,7 @@ fun FileViewerScreen( modifier = Modifier.size(Sizing.iconAction) ) } - if (!editMode) { + if (!editMode && uiState.capabilities.canWrite) { IconButton( onClick = { editMode = true }, modifier = Modifier @@ -124,7 +131,7 @@ fun FileViewerScreen( modifier = Modifier.size(Sizing.iconAction) ) } - } else { + } else if (editMode) { IconButton( onClick = { viewModel.requestSave() }, enabled = editState.isDirty && !editState.isSaving, @@ -187,26 +194,55 @@ fun FileViewerScreen( ) } fileContent != null -> { - SyntaxHighlightedCode( - code = fileContent, - filename = filename, - modifier = Modifier - .fillMaxSize() - .padding(Spacing.md), - showLineNumbers = showLineNumbers, - selectable = true - ) + Column(modifier = Modifier.fillMaxSize()) { + SyntaxHighlightedCode( + code = fileContent, + filename = filename, + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(Spacing.md), + showLineNumbers = showLineNumbers, + selectable = true + ) + if (uiState.capabilitiesLoaded && !uiState.capabilities.canWrite) { + Surface( + color = theme.backgroundPanel, + modifier = Modifier + .fillMaxWidth() + .testTag("file_viewer_read_only_message"), + ) { + Text( + text = stringResource(R.string.file_editor_read_only), + style = MaterialTheme.typography.bodySmall, + color = theme.textMuted, + modifier = Modifier.padding(Spacing.sm), + ) + } + } + } } error != null -> { - Text( - text = error, + Column( modifier = Modifier.align(Alignment.Center), - color = theme.error - ) + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(Spacing.sm), + ) { + Text( + text = stringResource(R.string.files_load_failed_hint), + color = theme.error, + ) + TuiButton( + onClick = { viewModel.loadFileContent(path) }, + modifier = Modifier.testTag("file_viewer_retry"), + ) { + Text(stringResource(R.string.retry)) + } + } } } - editState.saveError?.let { msg -> + editState.saveError?.let { TuiSnackbar( modifier = Modifier .align(Alignment.BottomCenter) @@ -217,7 +253,7 @@ fun FileViewerScreen( } } ) { - Text(stringResource(R.string.file_editor_save_failed, msg)) + Text(stringResource(R.string.file_editor_save_failed)) } } } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/files/FilesViewModel.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/files/FilesViewModel.kt index b397cc27..e24e7d64 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/files/FilesViewModel.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/files/FilesViewModel.kt @@ -19,6 +19,9 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +/** Conservative share of the Bundle budget for both UTF-16 edit buffers. */ +internal const val MAX_SAVED_EDIT_CONTENT_CHARS = 64 * 1024 + @Suppress("TooManyFunctions") class FilesViewModel constructor( private val fileRepository: FileRepository, @@ -60,31 +63,53 @@ class FilesViewModel constructor( } private fun restoredEditState(): FileEditState { - val path = savedStateHandle.get(KEY_EDIT_PATH) ?: return FileEditState() - val originalContent = savedStateHandle[KEY_EDIT_ORIGINAL_CONTENT] ?: "" - val currentContent = savedStateHandle[KEY_EDIT_CURRENT_CONTENT] ?: originalContent - return FileEditState( - path = path, - originalContent = originalContent, - currentContent = currentContent, - isDirty = currentContent != originalContent, - contentGeneration = 1, - baselineHash = savedStateHandle[KEY_EDIT_BASELINE_HASH], - ) + val path = savedStateHandle.get(KEY_EDIT_PATH) + val originalContent = savedStateHandle.get(KEY_EDIT_ORIGINAL_CONTENT) + val currentContent = savedStateHandle.get(KEY_EDIT_CURRENT_CONTENT) + return if (path == null) { + FileEditState() + } else if (originalContent == null || currentContent == null) { + clearPersistedEditState() + FileEditState() + } else { + FileEditState( + path = path, + originalContent = originalContent, + currentContent = currentContent, + isDirty = currentContent != originalContent, + contentGeneration = 1, + baselineHash = savedStateHandle[KEY_EDIT_BASELINE_HASH], + ) + } } private fun persistEditState(state: FileEditState) { - if (state.path == null) { - savedStateHandle.remove(KEY_EDIT_PATH) - savedStateHandle.remove(KEY_EDIT_ORIGINAL_CONTENT) - savedStateHandle.remove(KEY_EDIT_CURRENT_CONTENT) - savedStateHandle.remove(KEY_EDIT_BASELINE_HASH) + val currentContentFits = + state.currentContent.length <= MAX_SAVED_EDIT_CONTENT_CHARS - state.originalContent.length + if (state.path == null || + state.originalContent.length > MAX_SAVED_EDIT_CONTENT_CHARS || + !currentContentFits + ) { + clearPersistedEditState() return } - savedStateHandle[KEY_EDIT_PATH] = state.path + + // The path is the snapshot's commit marker. Invalidate the prior snapshot + // before replacing its contents, then publish the new path last. + savedStateHandle.remove(KEY_EDIT_PATH) savedStateHandle[KEY_EDIT_ORIGINAL_CONTENT] = state.originalContent savedStateHandle[KEY_EDIT_CURRENT_CONTENT] = state.currentContent savedStateHandle[KEY_EDIT_BASELINE_HASH] = state.baselineHash + savedStateHandle[KEY_EDIT_PATH] = state.path + } + + private fun clearPersistedEditState() { + // Remove the commit marker first. An oversized in-memory edit must restore + // by reloading the file, never from partial or stale persisted contents. + savedStateHandle.remove(KEY_EDIT_PATH) + savedStateHandle.remove(KEY_EDIT_ORIGINAL_CONTENT) + savedStateHandle.remove(KEY_EDIT_CURRENT_CONTENT) + savedStateHandle.remove(KEY_EDIT_BASELINE_HASH) } private fun updateEditState(transform: (FileEditState) -> FileEditState) { @@ -99,6 +124,9 @@ class FilesViewModel constructor( fun navigateTo(path: String) { pathStack.add(_uiState.value.currentPath) + if (pathStack.size > MAX_PERSISTED_PATH_DEPTH) { + pathStack.removeAt(0) + } loadFiles(path) } @@ -113,8 +141,9 @@ class FilesViewModel constructor( } fun updateSearchQuery(query: String) { - savedStateHandle[KEY_SEARCH_QUERY] = query - _uiState.update { it.copy(searchQuery = query) } + val bounded = query.take(MAX_PERSISTED_QUERY_CHARS) + savedStateHandle[KEY_SEARCH_QUERY] = bounded + _uiState.update { it.copy(searchQuery = bounded) } } fun setSymbolMode(active: Boolean) { @@ -123,9 +152,10 @@ class FilesViewModel constructor( } fun updateSymbolQuery(query: String) { - savedStateHandle[KEY_SYMBOL_QUERY] = query - _uiState.update { it.copy(symbolQuery = query) } - searchSymbols(query) + val bounded = query.take(MAX_PERSISTED_QUERY_CHARS) + savedStateHandle[KEY_SYMBOL_QUERY] = bounded + _uiState.update { it.copy(symbolQuery = bounded) } + searchSymbols(bounded) } fun clearFilters() { @@ -189,7 +219,12 @@ class FilesViewModel constructor( private fun loadCapabilities() { viewModelScope.launch { - _uiState.update { it.copy(capabilities = fileRepository.capabilities()) } + _uiState.update { + it.copy( + capabilities = fileRepository.capabilities(), + capabilitiesLoaded = true, + ) + } } } @@ -258,7 +293,12 @@ class FilesViewModel constructor( } } + @Suppress("ReturnCount") fun requestSave() { + if (!_uiState.value.capabilities.canWrite) { + clearPendingSaveState() + return + } val state = _editState.value if (state.path == null) return if (!state.isDirty) { @@ -293,11 +333,20 @@ class FilesViewModel constructor( /** Re-issues the write with no baseline hash, suppressing stale-write detection. */ fun overwriteAnyway() { + if (!_uiState.value.capabilities.canWrite) { + clearPendingSaveState() + return + } updateEditState { it.copy(conflict = null) } performSave(useBaselineHash = false) } + @Suppress("ReturnCount") private fun performSave(useBaselineHash: Boolean) { + if (!_uiState.value.capabilities.canWrite) { + clearPendingSaveState() + return + } val state = _editState.value val path = state.path ?: return if (state.isSaving) return @@ -370,6 +419,16 @@ class FilesViewModel constructor( updateEditState { it.copy(saveError = null) } } + private fun clearPendingSaveState() { + updateEditState { + it.copy( + isSaving = false, + pendingSavePreview = null, + conflict = null, + ) + } + } + fun uploadFromSources(source: UploadSource, sourceIds: List) { uploadCoordinator.upload( source = source, @@ -462,7 +521,9 @@ class FilesViewModel constructor( uploadCoordinator.dismiss() } - private companion object { + companion object { + private const val MAX_PERSISTED_QUERY_CHARS = 1_024 + private const val MAX_PERSISTED_PATH_DEPTH = 128 const val ROOT_PATH = "" const val KEY_CURRENT_PATH = "files_current_path" const val KEY_PATH_STACK = "files_path_stack" @@ -496,6 +557,7 @@ data class FilesUiState( val isSymbolMode: Boolean = false, val symbolQuery: String = "", val capabilities: FileCapabilities = FileCapabilities(), + val capabilitiesLoaded: Boolean = false, val isMutating: Boolean = false, val mutationMessage: String? = null, ) diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/files/editor/SoraTextMateBootstrap.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/files/editor/SoraTextMateBootstrap.kt index fa03b46c..21aebf7d 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/files/editor/SoraTextMateBootstrap.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/files/editor/SoraTextMateBootstrap.kt @@ -1,9 +1,9 @@ package dev.blazelight.p4oc.ui.screens.files.editor import android.content.Context -import android.util.Log import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb +import dev.blazelight.p4oc.core.log.AppLog import dev.blazelight.p4oc.ui.components.code.OpenCodeScopeColors import dev.blazelight.p4oc.ui.theme.opencode.OpenCodeTheme import io.github.rosemoe.sora.langs.textmate.registry.FileProviderRegistry @@ -66,7 +66,7 @@ internal object SoraTextMateBootstrap { .addFileProvider(AssetsFileResolver(appCtx.assets)) GrammarRegistry.getInstance().loadGrammars(LANGUAGES_CONFIG) }.onFailure { t -> - Log.w(TAG, "Grammar bootstrap failed; editor will fall back to plain text", t) + AppLog.w(TAG, "Grammar bootstrap failed; editor will fall back to plain text") // Mark as loaded anyway: retrying mid-session will not help and we // don't want every editor open to repeat the same failing load. } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/files/upload/UploadCoordinator.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/files/upload/UploadCoordinator.kt index de53f620..0be74c9d 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/files/upload/UploadCoordinator.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/files/upload/UploadCoordinator.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicLong class UploadCoordinator( private val scope: CoroutineScope, @@ -24,6 +25,7 @@ class UploadCoordinator( private var uploadJob: Job? = null private var orchestrator: UploadOrchestrator? = null private var callbacks: UploadCallbacks? = null + private val generation = AtomicLong() fun upload( source: UploadSource, @@ -39,6 +41,7 @@ class UploadCoordinator( return } + val currentGeneration = generation.incrementAndGet() val currentOrchestrator = UploadOrchestrator( fileRepository = repositoryFactory(), source = source, @@ -48,7 +51,9 @@ class UploadCoordinator( uploadJob?.cancel() uploadJob = scope.launch(Dispatchers.IO) { val mirrorJob = launch { - currentOrchestrator.state.collect { _state.value = it } + currentOrchestrator.state.collect { + if (generation.get() == currentGeneration) _state.value = it + } } try { val plans = sourceIds.map { id -> @@ -63,8 +68,10 @@ class UploadCoordinator( ) } val finalState = currentOrchestrator.run(currentCallbacks.destinationPath, plans) - _state.value = finalState - currentCallbacks.onComplete(finalState.successes) + if (generation.get() == currentGeneration) { + _state.value = finalState + currentCallbacks.onComplete(finalState.successes) + } } finally { mirrorJob.cancel() } @@ -74,16 +81,21 @@ class UploadCoordinator( fun retryFailed() { val currentOrchestrator = orchestrator ?: return if (_state.value.failures.isEmpty() || _state.value.isActive) return + val currentGeneration = generation.incrementAndGet() uploadJob?.cancel() uploadJob = scope.launch(Dispatchers.IO) { val mirrorJob = launch { - currentOrchestrator.state.collect { _state.value = it } + currentOrchestrator.state.collect { + if (generation.get() == currentGeneration) _state.value = it + } } try { currentOrchestrator.retryFailed() val finalState = currentOrchestrator.state.value - _state.value = finalState - callbacks?.onComplete(finalState.successes) + if (generation.get() == currentGeneration) { + _state.value = finalState + callbacks?.onComplete(finalState.successes) + } } finally { mirrorJob.cancel() } @@ -93,17 +105,18 @@ class UploadCoordinator( fun cancel() { val currentJob = uploadJob ?: return val currentOrchestrator = orchestrator ?: return + generation.incrementAndGet() currentOrchestrator.markCancelled() _state.value = currentOrchestrator.state.value uploadJob = null scope.launch { currentJob.cancelAndJoin() - _state.value = currentOrchestrator.state.value } } fun dismiss() { if (_state.value.isActive) return + generation.incrementAndGet() _state.value = UploadQueueState() orchestrator = null callbacks = null diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/files/upload/UploadOrchestrator.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/files/upload/UploadOrchestrator.kt index 02cb425f..e068f3b4 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/files/upload/UploadOrchestrator.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/files/upload/UploadOrchestrator.kt @@ -3,11 +3,14 @@ package dev.blazelight.p4oc.ui.screens.files.upload import dev.blazelight.p4oc.data.files.FileOperationResult import dev.blazelight.p4oc.data.files.FileRepository import dev.blazelight.p4oc.data.files.FileUploadRequest +import dev.blazelight.p4oc.data.files.ofish.MAX_UPLOAD_SOURCE_BYTES +import dev.blazelight.p4oc.data.files.ofish.UPLOAD_TOO_LARGE_MESSAGE import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import java.util.concurrent.atomic.AtomicLong /** * Drives a serial whole-file upload batch and exposes per-item progress as @@ -29,6 +32,7 @@ class UploadOrchestrator( ) { private val _state = MutableStateFlow(UploadQueueState()) val state: StateFlow = _state.asStateFlow() + private val operationGeneration = AtomicLong() data class Plan( val sourceId: String, @@ -43,7 +47,9 @@ class UploadOrchestrator( * caller controls the scope/dispatcher. Safe to cancel via the calling * coroutine. */ + @Suppress("ReturnCount") suspend fun run(currentPath: String?, plans: List): UploadQueueState { + val generation = operationGeneration.incrementAndGet() val items = plans.map { plan -> val sanitized = sanitizeUploadName(plan.displayName, now()) UploadItem( @@ -55,14 +61,22 @@ class UploadOrchestrator( probeFailure = plan.probeFailure, ) } - _state.value = UploadQueueState(items = items, currentIndex = 0, isActive = items.isNotEmpty()) + _state.update { state -> + if (operationGeneration.get() != generation) { + state + } else { + UploadQueueState(items = items, currentIndex = 0, isActive = items.isNotEmpty()) + } + } + if (operationGeneration.get() != generation) return _state.value items.forEachIndexed { index, _ -> - mutate { it.copy(currentIndex = index) } - uploadOne(index) + if (operationGeneration.get() != generation) return _state.value + mutate(generation) { it.copy(currentIndex = index) } + uploadOne(index, generation) } - mutate { it.copy(isActive = false) } + mutate(generation) { it.copy(isActive = false) } return _state.value } @@ -72,6 +86,7 @@ class UploadOrchestrator( * successful uploads. */ suspend fun retryFailed() { + val generation = operationGeneration.incrementAndGet() val snapshot = _state.value val failedIndices = snapshot.items.mapIndexedNotNull { i, item -> if (item.phase is UploadPhase.Failed) i else null @@ -86,10 +101,10 @@ class UploadOrchestrator( state.copy(items = items, isActive = true, cancelled = false) } for (idx in failedIndices) { - mutate { it.copy(currentIndex = idx) } - uploadOne(idx) + mutate(generation) { it.copy(currentIndex = idx) } + uploadOne(idx, generation) } - mutate { it.copy(isActive = false) } + mutate(generation) { it.copy(isActive = false) } } /** @@ -98,6 +113,7 @@ class UploadOrchestrator( * doesn't keep displaying it as active forever after the job is killed. */ fun markCancelled() { + operationGeneration.incrementAndGet() _state.update { state -> val items = state.items.map { item -> when (item.phase) { @@ -111,18 +127,31 @@ class UploadOrchestrator( } } - private suspend fun uploadOne(index: Int) { + @Suppress("CyclomaticComplexMethod", "ReturnCount") + private suspend fun uploadOne(index: Int, generation: Long) { + if (operationGeneration.get() != generation) return val item = _state.value.items.getOrNull(index) ?: return + if (item.bytesTotal > MAX_UPLOAD_SOURCE_BYTES) { + updateItem(index, generation) { it.copy(phase = UploadPhase.Failed(UPLOAD_TOO_LARGE_MESSAGE)) } + return + } var lastFailure: String? = null for (attempt in 1..maxAttempts) { - updateItem(index) { it.copy(phase = UploadPhase.Reading, attempts = attempt, bytesUploaded = 0L) } + if (operationGeneration.get() != generation) return + updateItem(index, generation) { + it.copy( + phase = UploadPhase.Reading, + attempts = attempt, + bytesUploaded = 0L, + ) + } val request = FileUploadRequest( path = item.destinationPath, contentLength = item.bytesTotal, openStream = { source.openStream(item.sourceId) }, expectedHash = null, onBytesUploaded = { uploaded -> - updateItem(index) { current -> + updateItem(index, generation) { current -> val total = if (current.bytesTotal > 0L) current.bytesTotal else uploaded current.copy( bytesTotal = total, @@ -133,35 +162,41 @@ class UploadOrchestrator( ) when (val result = fileRepository.uploadFile(request)) { is FileOperationResult.Ok -> { - updateItem(index) { it.copy(phase = UploadPhase.Done, bytesUploaded = it.bytesTotal) } + if (operationGeneration.get() != generation) return + updateItem(index, generation) { it.copy(phase = UploadPhase.Done, bytesUploaded = it.bytesTotal) } return } is FileOperationResult.Conflict -> { - updateItem(index) { + if (operationGeneration.get() != generation) return + updateItem(index, generation) { it.copy(phase = UploadPhase.Failed(result.message)) } return } is FileOperationResult.Failed -> { + if (operationGeneration.get() != generation) return lastFailure = result.message if (attempt < maxAttempts) delay(retryDelayMillis(attempt)) } } } - updateItem(index) { + updateItem(index, generation) { it.copy(phase = UploadPhase.Failed(lastFailure ?: "Upload failed")) } } - private fun updateItem(index: Int, transform: (UploadItem) -> UploadItem) { + private fun updateItem(index: Int, generation: Long, transform: (UploadItem) -> UploadItem) { _state.update { state -> + if (operationGeneration.get() != generation) return@update state val current = state.items.getOrNull(index) ?: return@update state state.copy(items = state.items.toMutableList().also { it[index] = transform(current) }) } } - private inline fun mutate(crossinline transform: (UploadQueueState) -> UploadQueueState) { - _state.update(transform) + private inline fun mutate(generation: Long, crossinline transform: (UploadQueueState) -> UploadQueueState) { + _state.update { state -> + if (operationGeneration.get() != generation) state else transform(state) + } } companion object { diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/files/upload/UploadProgressSheet.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/files/upload/UploadProgressSheet.kt index 6434ec7a..42a74891 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/files/upload/UploadProgressSheet.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/files/upload/UploadProgressSheet.kt @@ -181,7 +181,7 @@ private fun UploadItemRow(item: UploadItem) { UploadPhase.Reading -> stringResource(R.string.upload_phase_reading) to theme.info UploadPhase.Uploading -> stringResource(R.string.upload_phase_uploading) to theme.accent UploadPhase.Done -> stringResource(R.string.upload_phase_done) to theme.success - is UploadPhase.Failed -> (phase.message.ifBlank { stringResource(R.string.upload_phase_failed) }) to theme.error + is UploadPhase.Failed -> stringResource(R.string.upload_phase_failed) to theme.error } val probeFailure = item.probeFailure Row( @@ -197,7 +197,11 @@ private fun UploadItemRow(item: UploadItem) { color = theme.textMuted, ) Text( - text = if (probeFailure == null) item.displayName else "${item.displayName} · probe failed: $probeFailure", + text = if (probeFailure == null) { + item.displayName + } else { + stringResource(R.string.upload_probe_failed_for, item.displayName) + }, style = MaterialTheme.typography.bodyMedium, color = theme.text, modifier = Modifier.weight(1f), diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/home/HomeScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/home/HomeScreen.kt index eed19d7f..36f5dc50 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/home/HomeScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/home/HomeScreen.kt @@ -30,7 +30,7 @@ import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Chat +import androidx.compose.material.icons.automirrored.filled.Chat import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.Terminal import androidx.compose.material3.Icon @@ -46,14 +46,17 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.style.TextOverflow +import dev.blazelight.p4oc.R import dev.blazelight.p4oc.core.network.ConnectionState import dev.blazelight.p4oc.domain.model.SessionPresence import dev.blazelight.p4oc.domain.server.WorkspaceKey @@ -69,6 +72,7 @@ import java.util.concurrent.TimeUnit private const val RECENT_DAY_LIMIT = 30 private const val HOME_WORKSPACE_SHORTCUT_LIMIT = 3 +private const val DISABLED_FILTER_ALPHA = 0.5f data class HomeActions( val onBrowseSessions: (StartWorkTarget) -> Unit, val onBrowseAllSessions: () -> Unit = {}, @@ -282,6 +286,7 @@ private fun globalSearchOverrideLabel(enabledCount: Int): String = @Composable private fun homeSearchField(query: String, onQueryChange: (String) -> Unit) { val theme = LocalOpenCodeTheme.current + val searchDescription = stringResource(R.string.home_search_accessibility) BasicTextField( value = query, onValueChange = onQueryChange, @@ -290,8 +295,9 @@ private fun homeSearchField(query: String, onQueryChange: (String) -> Unit) { cursorBrush = androidx.compose.ui.graphics.SolidColor(theme.accent), modifier = Modifier .fillMaxWidth() - .height(Sizing.buttonHeightSm) + .height(Sizing.minTouchTarget) .border(Sizing.strokeThin, theme.border, RectangleShape) + .semantics { contentDescription = searchDescription } .testTag("home_search_field"), decorationBox = { field -> Row( @@ -449,7 +455,7 @@ private fun homeHeader(summary: HomeSummaryState, onServers: () -> Unit) { Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(Spacing.xxs)) { Text("[ Home ]", style = MaterialTheme.typography.titleMedium, color = LocalOpenCodeTheme.current.text) Text( - "${summary.sessions.size} sessions · ${summary.workspaces.size} workspaces", + stringResource(R.string.home_summary_counts, summary.sessions.size, summary.workspaces.size), style = MaterialTheme.typography.labelSmall, color = LocalOpenCodeTheme.current.textMuted, maxLines = 1, @@ -524,13 +530,19 @@ private fun serverToggleCard( color = theme.backgroundPanel, modifier = Modifier .width(Sizing.serverFilterCardWidth) + .alpha(if (searchActive) DISABLED_FILTER_ALPHA else 1f) .then( - if (enabled) Modifier.border(Sizing.strokeMd, theme.primary, RectangleShape) else Modifier, + if (enabled && !searchActive) { + Modifier.border(Sizing.strokeMd, theme.primary, RectangleShape) + } else { + Modifier + }, ) .toggleable( value = enabled, + enabled = !searchActive, role = Role.Checkbox, - onValueChange = { if (!searchActive) onToggle() }, + onValueChange = { onToggle() }, ) .semantics { stateDescription = if (searchActive) { @@ -730,7 +742,11 @@ private fun sessionRow( ) } if (session.isShared) { - Text("◈ Shared", style = MaterialTheme.typography.labelSmall, color = theme.info) + Text( + stringResource(R.string.home_shared_badge), + style = MaterialTheme.typography.labelSmall, + color = theme.info, + ) } } } @@ -871,7 +887,7 @@ private fun androidx.compose.foundation.lazy.LazyListScope.workspaceSessions(inp @Composable private fun openWorkCard(work: OpenWorkSummary, onFocus: () -> Unit) { val icon = when (work.type) { - OpenWorkType.Chat -> Icons.Default.Chat + OpenWorkType.Chat -> Icons.AutoMirrored.Filled.Chat OpenWorkType.Files -> Icons.Default.Folder OpenWorkType.Terminal -> Icons.Default.Terminal } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/home/HomeSummary.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/home/HomeSummary.kt index 016874a9..b2923e36 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/home/HomeSummary.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/home/HomeSummary.kt @@ -156,7 +156,7 @@ private fun appendRepositoryFailures( ) { repositories.mapNotNullTo(failures) { scoped -> (scoped.state as? RepoState.Stale)?.let { - "${scoped.serverRef.displayName}: ${it.reason ?: "session data unavailable"}" + "${scoped.serverRef.displayName}: session data unavailable" } } } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/licenses/LicensesScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/licenses/LicensesScreen.kt index da6106c6..17edafde 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/licenses/LicensesScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/licenses/LicensesScreen.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextOverflow +import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.blazelight.p4oc.R import dev.blazelight.p4oc.ui.components.TuiCard import dev.blazelight.p4oc.ui.components.TuiTextButton @@ -43,8 +44,8 @@ fun LicensesScreen( ) { val theme = LocalOpenCodeTheme.current val context = LocalContext.current - val texts by viewModel.texts.collectAsState() - val loading by viewModel.loadingTexts.collectAsState() + val texts by viewModel.texts.collectAsStateWithLifecycle() + val loading by viewModel.loadingTexts.collectAsStateWithLifecycle() val entries = remember { LicenseCatalogue.all } val requestChannelUrl = stringResource(R.string.licenses_request_channel_url) var expandedKey by remember { mutableStateOf(null) } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/projects/ProjectsScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/projects/ProjectsScreen.kt index c6ad250d..608b6725 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/projects/ProjectsScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/projects/ProjectsScreen.kt @@ -31,20 +31,24 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.blazelight.p4oc.R import dev.blazelight.p4oc.data.remote.dto.ProjectDto +import dev.blazelight.p4oc.data.workspace.WorkspaceClient import dev.blazelight.p4oc.ui.components.TuiButton import dev.blazelight.p4oc.ui.components.TuiLoadingScreen import dev.blazelight.p4oc.ui.components.TuiTopBar import dev.blazelight.p4oc.ui.theme.LocalOpenCodeTheme import dev.blazelight.p4oc.ui.theme.Spacing -import kotlinx.datetime.Instant import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf +import kotlin.time.Instant @OptIn(ExperimentalMaterial3Api::class) +@Suppress("FunctionNaming", "LongMethod") @Composable fun ProjectsScreen( - viewModel: ProjectsViewModel = koinViewModel(), + workspaceClient: WorkspaceClient, + viewModel: ProjectsViewModel = koinViewModel(parameters = { parametersOf(workspaceClient) }), onNavigateBack: (() -> Unit)? = null, onProjectClick: (projectId: String, worktree: String) -> Unit = { _, _ -> } ) { @@ -83,7 +87,7 @@ fun ProjectsScreen( color = theme.error ) Text( - text = uiState.error ?: "Unknown error", + text = stringResource(R.string.projects_load_failed), color = theme.error ) TuiButton(onClick = { viewModel.loadProjects() }) { @@ -166,7 +170,7 @@ private fun ProjectRow( val instant = Instant.fromEpochMilliseconds(project.time.created) val localDateTime = instant.toLocalDateTime(TimeZone.currentSystemDefault()) "${localDateTime.month.name.take(3).lowercase().replaceFirstChar { it.uppercase() }} " + - localDateTime.dayOfMonth.toString().padStart(2, '0') + localDateTime.day.toString().padStart(2, '0') } Surface( diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/projects/ProjectsViewModel.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/projects/ProjectsViewModel.kt index 5e54a82d..4a60d7a0 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/projects/ProjectsViewModel.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/projects/ProjectsViewModel.kt @@ -3,15 +3,21 @@ package dev.blazelight.p4oc.ui.screens.projects import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dev.blazelight.p4oc.core.network.ApiResult -import dev.blazelight.p4oc.core.network.ConnectionManager +import dev.blazelight.p4oc.core.network.ServerConnectionRegistry import dev.blazelight.p4oc.core.network.safeApiCall import dev.blazelight.p4oc.data.remote.dto.ProjectDto +import dev.blazelight.p4oc.data.workspace.WorkspaceClient +import dev.blazelight.p4oc.domain.model.OpenCodeEvent +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -22,51 +28,73 @@ data class ProjectsUiState( val error: String? = null ) +@OptIn(FlowPreview::class) class ProjectsViewModel constructor( - private val connectionManager: ConnectionManager + private val workspaceClient: WorkspaceClient, + serverConnectionRegistry: ServerConnectionRegistry, ) : ViewModel() { + companion object { + private const val EVENT_REFRESH_DEBOUNCE_MS = 150L + } + private val _uiState = MutableStateFlow(ProjectsUiState()) val uiState: StateFlow = _uiState.asStateFlow() init { loadProjects() + viewModelScope.launch { + serverConnectionRegistry.events(workspaceClient.workspace.server) + .filter { scopedEvent -> + scopedEvent.serverRef == workspaceClient.workspace.server && + scopedEvent.generation == workspaceClient.generation && + scopedEvent.workspaceKey == workspaceClient.workspace.key + } + .map { it.event } + .filter { event -> + event is OpenCodeEvent.ProjectUpdated || + event is OpenCodeEvent.ProjectDirectoriesUpdated + } + .debounce(EVENT_REFRESH_DEBOUNCE_MS) + .collect { refreshProjects() } + } } fun loadProjects() { viewModelScope.launch { - _uiState.update { it.copy(isLoading = true, error = null) } - val api = connectionManager.getApi() ?: run { - _uiState.update { it.copy(isLoading = false, error = "Not connected") } - return@launch - } - val result = safeApiCall { api.listProjects() } - when (result) { - is ApiResult.Success -> { - val projects = result.data.sortedByDescending { p -> p.time.created } - val accessibleProjects = filterAccessibleProjects(projects) - _uiState.update { - it.copy( - isLoading = false, - projects = accessibleProjects, - staleProjectCount = projects.size - accessibleProjects.size - ) - } - } - is ApiResult.Error -> { - _uiState.update { it.copy(isLoading = false, error = result.message) } + refreshProjects() + } + } + + private suspend fun refreshProjects() { + _uiState.update { it.copy(isLoading = true, error = null) } + val result = safeApiCall { workspaceClient.listProjects() } + when (result) { + is ApiResult.Success -> { + val projects = result.data.sortedByDescending { p -> p.time.created } + val accessibleProjects = filterAccessibleProjects(projects) + _uiState.update { + it.copy( + isLoading = false, + projects = accessibleProjects, + staleProjectCount = projects.size - accessibleProjects.size + ) } } + is ApiResult.Error -> { + _uiState.update { it.copy(isLoading = false, error = result.message) } + } } } private suspend fun filterAccessibleProjects(projects: List): List { - val api = connectionManager.getApi() ?: return projects + val projectDirectories = projects.mapTo(hashSetOf()) { it.worktree } return coroutineScope { projects.map { project -> async { + check(project.worktree in projectDirectories) val isAccessible = safeApiCall { - api.listFiles(path = project.worktree, directory = project.worktree) + workspaceClient.listProjectFiles(project.worktree) } is ApiResult.Success project.takeIf { isAccessible } } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerScreen.kt index ba16b69d..f78e5def 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerScreen.kt @@ -10,9 +10,11 @@ import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Login import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* @@ -36,6 +38,7 @@ import dev.blazelight.p4oc.core.datastore.SavedServer import dev.blazelight.p4oc.core.network.DiscoveredServer import dev.blazelight.p4oc.core.network.DiscoveryState import dev.blazelight.p4oc.core.network.ServerConnectionRegistry +import dev.blazelight.p4oc.core.network.ServerUrl import dev.blazelight.p4oc.core.network.toServerRef import dev.blazelight.p4oc.ui.components.TuiConfirmDialog import dev.blazelight.p4oc.ui.components.TuiLoadingIndicator @@ -66,7 +69,7 @@ fun serverScreen( saved.endpointKey to state } val tabManager: TabManager = koinInject() - val tabs by tabManager.tabs.collectAsState() + val tabs by tabManager.tabs.collectAsStateWithLifecycle() val openTabsByEndpoint = tabs.filterNot { it.isPinnedHome }.groupBy { it.serverEndpointKey } val inventory = remember(uiState, registryStates) { buildServerInventory(uiState, registryStates) } var showManualForm by rememberSaveable { @@ -393,11 +396,26 @@ private fun remoteServerSection( onTogglePassword = { passwordVisible = !passwordVisible }, ), ) + cleartextCredentialWarning(state) connectButton(state = state, onConnect = actions.onConnect) } } } +@Composable +private fun cleartextCredentialWarning(state: RemoteServerState) { + val usesCleartext = state.url.trimStart().startsWith("http://", ignoreCase = true) + val hasCredentials = state.username.isNotBlank() || state.password.isNotBlank() + val shouldWarn = usesCleartext && hasCredentials && ServerUrl.allowsCleartextCredentials(state.url) + if (!shouldWarn) return + Text( + text = stringResource(R.string.server_cleartext_credentials_warning), + color = LocalOpenCodeTheme.current.warning, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + ) +} + @Composable private fun remoteUrlField( value: TextFieldValue, @@ -473,9 +491,11 @@ private fun credentialsFields( Row( modifier = Modifier .fillMaxWidth() - .clickable(role = Role.Checkbox) { - actions.onAllowInsecureChange(!state.allowInsecure) - } + .toggleable( + value = state.allowInsecure, + role = Role.Checkbox, + onValueChange = actions.onAllowInsecureChange, + ) .padding(vertical = Spacing.xs) .testTag("server_allow_insecure_toggle"), verticalAlignment = Alignment.CenterVertically, @@ -483,7 +503,7 @@ private fun credentialsFields( ) { Checkbox( checked = state.allowInsecure, - onCheckedChange = actions.onAllowInsecureChange, + onCheckedChange = null, colors = CheckboxDefaults.colors(checkedColor = theme.accent), ) Column(Modifier.weight(1f)) { @@ -557,7 +577,7 @@ private fun connectButton(state: RemoteServerState, onConnect: () -> Unit) { Spacer(Modifier.width(Spacing.md)) Text(stringResource(R.string.button_connecting), fontFamily = FontFamily.Monospace) } else { - Icon(Icons.Default.Login, contentDescription = null) + Icon(Icons.AutoMirrored.Filled.Login, contentDescription = null) Spacer(Modifier.width(Spacing.sm)) Text(stringResource(R.string.button_connect), fontFamily = FontFamily.Monospace) } @@ -904,7 +924,9 @@ private fun savedServerEditorForm(presentation: SavedServerEditorPresentation, o Column(Modifier.padding(Spacing.md), verticalArrangement = Arrangement.spacedBy(Spacing.sm)) { Row(Modifier.fillMaxWidth(), Arrangement.SpaceBetween, Alignment.CenterVertically) { Text("[ ${server.displayName} ]", fontFamily = FontFamily.Monospace, color = theme.text) - IconButton(onClick = presentation.onDismiss) { Icon(Icons.Default.Close, "Close server details") } + IconButton(onClick = presentation.onDismiss) { + Icon(Icons.Default.Close, stringResource(R.string.server_close_details)) + } } remoteServerSection( RemoteServerState( @@ -930,7 +952,7 @@ private fun savedServerEditorForm(presentation: SavedServerEditorPresentation, o ) { Icon(Icons.Default.Save, null) Spacer(Modifier.width(Spacing.sm)) - Text("Save", fontFamily = FontFamily.Monospace) + Text(stringResource(R.string.file_editor_save), fontFamily = FontFamily.Monospace) } TextButton(onRemove, Modifier.fillMaxWidth().testTag("saved_server_detail_forget")) { Text(stringResource(R.string.server_forget), color = theme.error) diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerViewModel.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerViewModel.kt index a1497291..268ea445 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerViewModel.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/server/ServerViewModel.kt @@ -1,19 +1,22 @@ +@file:Suppress("ImportOrdering") + package dev.blazelight.p4oc.ui.screens.server import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dev.blazelight.p4oc.core.datastore.RecentServer import dev.blazelight.p4oc.core.datastore.SavedServer +import dev.blazelight.p4oc.core.datastore.SavedServerRegistry import dev.blazelight.p4oc.core.datastore.SettingsDataStore import dev.blazelight.p4oc.core.log.AppLog -import dev.blazelight.p4oc.core.network.ConnectionManager import dev.blazelight.p4oc.core.network.ConnectionState import dev.blazelight.p4oc.core.network.DiscoveredServer import dev.blazelight.p4oc.core.network.DiscoverySeed import dev.blazelight.p4oc.core.network.DiscoveryState import dev.blazelight.p4oc.core.network.MdnsDiscoveryManager -import dev.blazelight.p4oc.core.network.ServerConfig +import dev.blazelight.p4oc.core.network.ServerConnectionRegistry import dev.blazelight.p4oc.core.network.ServerUrl +import dev.blazelight.p4oc.core.network.toServerConfig import dev.blazelight.p4oc.core.security.CredentialStore import dev.blazelight.p4oc.domain.server.ServerIdentity import kotlinx.coroutines.flow.MutableStateFlow @@ -73,7 +76,7 @@ private const val TAG = "ServerViewModel" class ServerViewModel constructor( private val settingsDataStore: SettingsDataStore, - private val connectionManager: ConnectionManager, + private val serverConnectionRegistry: ServerConnectionRegistry, private val credentialStore: CredentialStore, private val mdnsDiscoveryManager: MdnsDiscoveryManager, ) : ViewModel() { @@ -112,7 +115,7 @@ class ServerViewModel constructor( viewModelScope.launch { val (lastConfig, password) = settingsDataStore.getLastConnection() ?: return@launch - AppLog.d(TAG, "Found last connection: ${lastConfig.url}") + AppLog.d(TAG, "Found last connection") val endpointKey = ServerUrl.endpointKey(lastConfig.url) _uiState.update { it.copy( @@ -125,7 +128,13 @@ class ServerViewModel constructor( ) } - val result = connectionManager.connect(lastConfig, password) + val server = SavedServerRegistry.fromConnection( + url = lastConfig.url, + name = lastConfig.name, + username = lastConfig.username, + allowInsecure = lastConfig.allowInsecure, + ) + val result = serverConnectionRegistry.connectAndAwait(server, password) result.fold( onSuccess = { projects -> @@ -142,13 +151,13 @@ class ServerViewModel constructor( } }, onFailure = { error -> - AppLog.w(TAG, "Auto-reconnect failed: ${error.message}") + AppLog.w(TAG, "Auto-reconnect failed") _uiState.update { it.copy( isConnecting = false, connectingEndpointKey = null, failedEndpointKey = endpointKey, - error = "Could not reconnect: ${error.message}" + error = "Could not reconnect to the server. Check the address and connection." ) } } @@ -174,7 +183,7 @@ class ServerViewModel constructor( fun connectToRemote() { val state = _uiState.value - AppLog.d(TAG, "connectToRemote called, url='${state.remoteUrl}'") + AppLog.d(TAG, "connectToRemote called") if (state.remoteUrl.isBlank()) { AppLog.w(TAG, "URL is blank, showing error") @@ -185,10 +194,22 @@ class ServerViewModel constructor( viewModelScope.launch { val url = ServerUrl.normalizeConnectUrl(state.remoteUrl) if (url == null) { - AppLog.w(TAG, "Invalid server URL: '${state.remoteUrl}'") + AppLog.w(TAG, "Invalid server URL") _uiState.update { it.copy(isConnecting = false, error = "Invalid server URL") } return@launch } + val password = state.password.takeIf { it.isNotBlank() } + if (state.username.isNotBlank() && password != null && + !ServerUrl.allowsCleartextCredentials(url) + ) { + _uiState.update { + it.copy( + isConnecting = false, + error = "Credentials require HTTPS outside a private local network", + ) + } + return@launch + } val endpointKey = ServerUrl.endpointKey(url) _uiState.update { it.copy( @@ -198,19 +219,17 @@ class ServerViewModel constructor( error = null, ) } - AppLog.d(TAG, "Connecting to normalized URL: $url") + AppLog.d(TAG, "Connecting to normalized URL") val identity = ServerIdentity.derive(url, state.serverNameCandidate) - val config = ServerConfig( + val candidate = SavedServerRegistry.fromConnection( url = url, name = identity.displayName, - isLocal = false, username = state.username.takeIf { it.isNotBlank() }, - allowInsecure = state.allowInsecure + allowInsecure = state.allowInsecure, ) - val password = state.password.takeIf { it.isNotBlank() } - - val result = connectionManager.connect(config, password) + val config = candidate.toServerConfig() + val result = serverConnectionRegistry.connectAndAwait(candidate, password) result.fold( onSuccess = { projects -> @@ -245,7 +264,7 @@ class ServerViewModel constructor( } }, onFailure = { error -> - AppLog.e(TAG, "Connection failed: ${error.message}", error) + AppLog.e(TAG, "Connection failed") // Clear password from UI state on failure too - user can re-enter _uiState.update { it.copy( @@ -253,7 +272,7 @@ class ServerViewModel constructor( connectingEndpointKey = null, failedEndpointKey = endpointKey, password = "", - error = "Failed to connect: ${error.message}" + error = "Could not connect to the server. Check the address, credentials, and connection." ) } } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/sessions/SessionListScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/sessions/SessionListScreen.kt index eecdc04b..02c9fe75 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/sessions/SessionListScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/sessions/SessionListScreen.kt @@ -44,10 +44,10 @@ import dev.blazelight.p4oc.ui.theme.LocalOpenCodeTheme import dev.blazelight.p4oc.ui.theme.ProjectColors import dev.blazelight.p4oc.ui.theme.Sizing import dev.blazelight.p4oc.ui.theme.Spacing -import kotlinx.datetime.Instant import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime import org.koin.androidx.compose.koinViewModel +import kotlin.time.Instant private data class SessionNode( val sessionWithProject: SessionWithProject, @@ -483,18 +483,28 @@ private data class SearchStatusVisual( ) private fun buildSessionTree(sessions: List): List { + val sessionIds = sessions.map { it.session.id }.toSet() val childrenByParent = sessions .mapNotNull { swp -> swp.session.parentID?.let { parentId -> parentId to swp } } .groupBy({ it.first }, { it.second }) - fun buildNode(sessionWithProject: SessionWithProject): SessionNode { - val children = childrenByParent[sessionWithProject.session.id]?.map { buildNode(it) } ?: emptyList() + val included = mutableSetOf() + fun buildNode(sessionWithProject: SessionWithProject, ancestors: Set): SessionNode { + val id = sessionWithProject.session.id + included += id + val children = childrenByParent[id] + .orEmpty() + .filterNot { it.session.id in ancestors || it.session.id == id } + .map { buildNode(it, ancestors + id) } return SessionNode(sessionWithProject, children) } - return sessions - .filter { it.session.parentID == null } - .map { buildNode(it) } + val roots = sessions.filter { it.session.parentID == null || it.session.parentID !in sessionIds } + .map { buildNode(it, emptySet()) } + .toMutableList() + sessions.filterNot { it.session.id in included } + .forEach { roots += buildNode(it, emptySet()) } + return roots } @Composable @@ -683,7 +693,7 @@ private fun SessionCard( ) { if (childCount > 0) { Text( - text = "[$childCount sub]", + text = "[${stringResource(R.string.sessions_sub_count, childCount)}]", style = MaterialTheme.typography.labelSmall, color = theme.info ) @@ -805,11 +815,12 @@ private fun ProjectChip( projectName: String, onClick: (() -> Unit)? ) { + val openDescription = stringResource(R.string.sessions_open_project, projectName) val modifier = Modifier .padding(start = Spacing.md) .widthIn(max = Sizing.chipMaxWidth) .testTag("session_directory_chip_$projectName") - .semantics { contentDescription = "Open sessions for $projectName" } + .semantics { contentDescription = openDescription } Surface( onClick = onClick ?: {}, enabled = onClick != null, @@ -848,7 +859,7 @@ private fun SessionStatusIndicator(status: SessionStatus?, presence: SessionPres private fun formatDateTime(epochMillis: Long): String { val instant = Instant.fromEpochMilliseconds(epochMillis) val local = instant.toLocalDateTime(TimeZone.currentSystemDefault()) - return "${local.monthNumber}/${local.dayOfMonth}/${local.year} ${local.hour}:${local.minute.toString().padStart( + return "${local.month.ordinal + 1}/${local.day}/${local.year} ${local.hour}:${local.minute.toString().padStart( 2, '0' )}" diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/sessions/SessionListViewModel.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/sessions/SessionListViewModel.kt index 49993b51..7a3b8ac5 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/sessions/SessionListViewModel.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/sessions/SessionListViewModel.kt @@ -3,6 +3,7 @@ package dev.blazelight.p4oc.ui.screens.sessions import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import dev.blazelight.p4oc.core.log.AppLog import dev.blazelight.p4oc.data.remote.dto.ProjectDto import dev.blazelight.p4oc.data.session.RepoState import dev.blazelight.p4oc.data.session.SessionRepositoryImpl @@ -21,6 +22,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull +@Suppress("TooManyFunctions") class SessionListViewModel constructor( private val sessionRepository: SessionRepositoryImpl, private val savedStateHandle: SavedStateHandle = SavedStateHandle(), @@ -29,26 +31,79 @@ class SessionListViewModel constructor( private val _uiState = MutableStateFlow(SessionListUiState()) val uiState: StateFlow = _uiState.asStateFlow() - private companion object { - const val LOAD_TIMEOUT_MS = 30_000L - const val SEARCH_DEBOUNCE_MS = 300L - const val KEY_SEARCH_QUERIES = "session_list_search_queries" - const val KEY_EXPANDED_SESSIONS = "session_list_expanded_sessions" - const val GLOBAL_CONTEXT_KEY = "__global__" + companion object { + internal const val MAX_SEARCH_QUERY_CHARS = 512 + internal const val MAX_SAVED_CONTEXTS = 16 + internal const val MAX_EXPANDED_SESSION_IDS_PER_CONTEXT = 64 + internal const val MAX_CONTEXT_KEY_CHARS = 1_024 + internal const val MAX_SESSION_ID_CHARS = 256 + + private const val TAG = "SessionListViewModel" + private const val LOAD_TIMEOUT_MS = 30_000L + private const val SEARCH_DEBOUNCE_MS = 300L + private const val KEY_SEARCH_QUERIES = "session_list_search_queries" + private const val KEY_EXPANDED_SESSIONS = "session_list_expanded_sessions" + private const val KEY_CONTEXT_RECENCY = "session_list_context_recency" + private const val GLOBAL_CONTEXT_KEY = "__global__" } private var searchJob: Job? = null + private val restoredContextRecency = savedStateHandle.get>(KEY_CONTEXT_RECENCY) + .orEmpty() + .filter(::isPersistableContext) + .distinct() + .takeLast(MAX_SAVED_CONTEXTS) + private val contextRecency = LinkedHashSet(restoredContextRecency) private val searchQueriesByContext = restoredStringMap(KEY_SEARCH_QUERIES) private val expandedSessionIdsByContext = restoredStringSetMap(KEY_EXPANDED_SESSIONS) - private fun restoredStringMap(key: String): MutableMap = - savedStateHandle.get>(key)?.toMutableMap() ?: mutableMapOf() + private fun restoredStringMap(key: String): MutableMap { + val restored = savedStateHandle.get>(key).orEmpty() + .filterKeys(::isPersistableContext) + .mapValues { (_, query) -> boundedQuery(query) } + return retainedContexts(restored.keys) + .mapNotNull { context -> restored[context]?.let { context to it } } + .toMap(LinkedHashMap()) + } + + private fun restoredStringSetMap(key: String): MutableMap> { + val restored = savedStateHandle.get>>(key).orEmpty() + .filterKeys(::isPersistableContext) + return retainedContexts(restored.keys) + .mapNotNull { context -> + restored[context]?.filter(::isPersistableSessionId) + ?.distinct() + ?.takeLast(MAX_EXPANDED_SESSION_IDS_PER_CONTEXT) + ?.toCollection(LinkedHashSet()) + ?.let { context to it } + } + .toMap(LinkedHashMap()) + } + + private fun retainedContexts(contexts: Set): List { + val ordered = contextRecency.filter { it in contexts } + + contexts.filterNot { it in contextRecency }.sorted() + return ordered.takeLast(MAX_SAVED_CONTEXTS) + } + + private fun touchContext(key: String) { + if (!isPersistableContext(key)) return + contextRecency.remove(key) + contextRecency.add(key) + while (contextRecency.size > MAX_SAVED_CONTEXTS) { + val evicted = contextRecency.first() + contextRecency.remove(evicted) + searchQueriesByContext.remove(evicted) + expandedSessionIdsByContext.remove(evicted) + } + savedStateHandle[KEY_CONTEXT_RECENCY] = ArrayList(contextRecency) + } + + private fun boundedQuery(query: String): String = query.take(MAX_SEARCH_QUERY_CHARS) + + private fun isPersistableContext(key: String): Boolean = key.length <= MAX_CONTEXT_KEY_CHARS - private fun restoredStringSetMap(key: String): MutableMap> = - savedStateHandle.get>>(key) - ?.mapValues { (_, value) -> value.toSet() } - ?.toMutableMap() - ?: mutableMapOf() + private fun isPersistableSessionId(id: String): Boolean = id.length <= MAX_SESSION_ID_CHARS private fun persistSearchQueries() { savedStateHandle[KEY_SEARCH_QUERIES] = HashMap(searchQueriesByContext) @@ -63,6 +118,15 @@ class SessionListViewModel constructor( private fun contextKey(directory: String?): String = directory ?: GLOBAL_CONTEXT_KEY init { + val retained = retainedContexts(searchQueriesByContext.keys + expandedSessionIdsByContext.keys).toSet() + searchQueriesByContext.keys.retainAll(retained) + expandedSessionIdsByContext.keys.retainAll(retained) + contextRecency.retainAll(retained) + retained.forEach { contextRecency.add(it) } + persistSearchQueries() + persistExpandedSessions() + savedStateHandle[KEY_CONTEXT_RECENCY] = ArrayList(contextRecency) + viewModelScope.launch { sessionRepository.state.collect { repoState -> val snapshot = repoState.snapshot @@ -99,7 +163,11 @@ class SessionListViewModel constructor( ) }, searchResults = if (state.searchQuery.isBlank()) emptyList() else state.searchResults, - error = (repoState as? RepoState.Stale)?.reason ?: state.error, + error = if (repoState is RepoState.Stale) { + "Could not refresh sessions. Showing the last loaded sessions." + } else { + state.error + }, ) } } @@ -154,7 +222,7 @@ class SessionListViewModel constructor( loadingText = null, loadingProgress = null, loadingCounts = null, - error = "Failed to load sessions: ${error.message}" + error = "Could not load sessions. Check the connection and try again." ) } }, @@ -164,21 +232,27 @@ class SessionListViewModel constructor( fun updateSearchQuery(query: String, directory: String?) { val key = contextKey(directory) - searchQueriesByContext[key] = query + val boundedQuery = boundedQuery(query) + touchContext(key) + if (isPersistableContext(key)) searchQueriesByContext[key] = boundedQuery persistSearchQueries() + persistExpandedSessions() _uiState.update { state -> state.copy( - searchQuery = query, + searchQuery = boundedQuery, searchDirectory = directory, searchError = null, - searchResults = if (query.isBlank()) emptyList() else state.searchResults, + searchResults = if (boundedQuery.isBlank()) emptyList() else state.searchResults, ) } - searchSessions(query, directory, debounce = true) + searchSessions(boundedQuery, directory, debounce = true) } fun updateSearchDirectory(directory: String?) { val key = contextKey(directory) + touchContext(key) + persistSearchQueries() + persistExpandedSessions() val restoredQuery = searchQueriesByContext[key].orEmpty() val restoredExpanded = expandedSessionIdsByContext[key].orEmpty() _uiState.update { @@ -198,9 +272,17 @@ class SessionListViewModel constructor( fun toggleSessionExpanded(sessionId: String) { val key = contextKey(_uiState.value.searchDirectory) + touchContext(key) val current = _uiState.value.expandedSessionIds - val next = if (sessionId in current) current - sessionId else current + sessionId - expandedSessionIdsByContext[key] = next + val next = when { + sessionId in current -> current - sessionId + !isPersistableSessionId(sessionId) -> current + else -> (current.toList() + listOf(sessionId)) + .takeLast(MAX_EXPANDED_SESSION_IDS_PER_CONTEXT) + .toCollection(LinkedHashSet()) + } + if (isPersistableContext(key)) expandedSessionIdsByContext[key] = next + persistSearchQueries() persistExpandedSessions() _uiState.update { it.copy(expandedSessionIds = next) } } @@ -260,7 +342,7 @@ class SessionListViewModel constructor( if (state.searchQuery.trim() == trimmed && state.searchDirectory == directory) { state.copy( isSearching = false, - searchError = "Search failed: ${error.message ?: "Unknown error"}", + searchError = "Could not search sessions. Check the connection and try again.", ) } else { state @@ -271,7 +353,7 @@ class SessionListViewModel constructor( } } - fun createSession(title: String?, directory: String? = null) { + fun createSession(title: String?, directory: String?) { viewModelScope.launch { _uiState.update { it.copy(isLoading = true, loadingText = "Creating session", error = null) } try { @@ -301,13 +383,14 @@ class SessionListViewModel constructor( } catch (e: CancellationException) { throw e } catch (e: Exception) { + AppLog.e(TAG, "Failed to create session") _uiState.update { it.copy( isLoading = false, loadingText = null, loadingProgress = null, loadingCounts = null, - error = "Failed to create session: ${e.message}" + error = "Could not create the session. Try again." ) } } @@ -321,7 +404,8 @@ class SessionListViewModel constructor( } catch (e: CancellationException) { throw e } catch (e: Exception) { - _uiState.update { it.copy(error = "Failed to delete session: ${e.message}") } + AppLog.e(TAG, "Failed to delete session") + _uiState.update { it.copy(error = "Could not delete the session. Try again.") } } } } @@ -337,7 +421,8 @@ class SessionListViewModel constructor( } catch (e: CancellationException) { throw e } catch (e: Exception) { - _uiState.update { it.copy(error = "Failed to rename: ${e.message}") } + AppLog.e(TAG, "Failed to rename session") + _uiState.update { it.copy(error = "Could not rename the session. Try again.") } } } } @@ -350,7 +435,8 @@ class SessionListViewModel constructor( } catch (e: CancellationException) { throw e } catch (e: Exception) { - _uiState.update { it.copy(error = "Failed to share session: ${e.message}") } + AppLog.e(TAG, "Failed to share session") + _uiState.update { it.copy(error = "Could not share the session. Try again.") } } } } @@ -362,7 +448,8 @@ class SessionListViewModel constructor( } catch (e: CancellationException) { throw e } catch (e: Exception) { - _uiState.update { it.copy(error = "Failed to unshare: ${e.message}") } + AppLog.e(TAG, "Failed to unshare session") + _uiState.update { it.copy(error = "Could not stop sharing the session. Try again.") } } } } @@ -378,7 +465,8 @@ class SessionListViewModel constructor( } catch (e: CancellationException) { throw e } catch (e: Exception) { - _uiState.update { it.copy(error = "Failed to summarize: ${e.message}") } + AppLog.e(TAG, "Failed to summarize session") + _uiState.update { it.copy(error = "Could not summarize the session. Try again.") } } } } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/AgentsConfigScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/AgentsConfigScreen.kt index d6de80f7..aa74cbc9 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/AgentsConfigScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/AgentsConfigScreen.kt @@ -18,9 +18,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewModelScope import dev.blazelight.p4oc.R import dev.blazelight.p4oc.core.network.ApiResult -import dev.blazelight.p4oc.core.network.ConnectionManager import dev.blazelight.p4oc.core.network.safeApiCall +import dev.blazelight.p4oc.data.workspace.WorkspaceClient import dev.blazelight.p4oc.ui.components.TuiAlertDialog +import dev.blazelight.p4oc.ui.components.TuiBadge import dev.blazelight.p4oc.ui.components.TuiButton import dev.blazelight.p4oc.ui.components.TuiLoadingScreen import dev.blazelight.p4oc.ui.components.TuiTextButton @@ -34,7 +35,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import org.koin.androidx.compose.koinViewModel data class AgentInfo( val name: String, @@ -53,7 +53,8 @@ data class AgentsConfigState( ) class AgentsConfigViewModel constructor( - private val connectionManager: ConnectionManager + private val workspaceClient: WorkspaceClient, + serverConnectionRegistry: dev.blazelight.p4oc.core.network.ServerConnectionRegistry? = null, ) : ViewModel() { private val _state = MutableStateFlow(AgentsConfigState()) @@ -61,16 +62,21 @@ class AgentsConfigViewModel constructor( init { loadAgents() + serverConnectionRegistry?.observeWorkspaceCatalogEvents( + workspaceClient, + viewModelScope, + includeMcp = true, + ) { + loadAgents(background = true) + } } - fun loadAgents() { + fun loadAgents() = loadAgents(background = false) + + private fun loadAgents(background: Boolean) { viewModelScope.launch { - _state.update { it.copy(isLoading = true) } - val api = connectionManager.getApi() ?: run { - _state.update { it.copy(isLoading = false, error = "Not connected") } - return@launch - } - val result = safeApiCall { api.getAgents() } + if (!background) _state.update { it.copy(isLoading = true) } + val result = safeApiCall { workspaceClient.getAgents() } when (result) { is ApiResult.Success -> { val agents = result.data.map { dto -> @@ -83,10 +89,17 @@ class AgentsConfigViewModel constructor( isBuiltIn = dto.isBuiltIn ?: dto.builtIn ) } - _state.update { it.copy(agents = agents, isLoading = false) } + _state.update { it.copy(agents = agents, isLoading = false, error = null) } } is ApiResult.Error -> { - _state.update { it.copy(isLoading = false, error = result.message) } + if (!background) { + _state.update { + it.copy( + isLoading = false, + error = "Could not load agents. Check the connection and try again.", + ) + } + } } } } @@ -95,33 +108,16 @@ class AgentsConfigViewModel constructor( fun selectAgent(agent: AgentInfo?) { _state.update { it.copy(selectedAgent = agent) } } - - fun clearError() { - _state.update { it.copy(error = null) } - } } @OptIn(ExperimentalMaterial3Api::class) +@Suppress("FunctionNaming", "LongMethod", "NoNameShadowing") @Composable fun AgentsConfigScreen( - viewModel: AgentsConfigViewModel = koinViewModel(), + viewModel: AgentsConfigViewModel, onNavigateBack: () -> Unit ) { val state by viewModel.state.collectAsStateWithLifecycle() - val snackbarHostState = remember { SnackbarHostState() } - val dismissLabel = stringResource(R.string.dismiss) - - LaunchedEffect(state.error) { - state.error?.let { error -> - snackbarHostState.showSnackbar( - message = error, - actionLabel = dismissLabel, - duration = SnackbarDuration.Long - ) - viewModel.clearError() - } - } - val theme = LocalOpenCodeTheme.current Scaffold( containerColor = theme.background, @@ -142,8 +138,7 @@ fun AgentsConfigScreen( } } ) - }, - snackbarHost = { SnackbarHost(snackbarHostState) } + } ) { padding -> if (state.isLoading) { TuiLoadingScreen( @@ -251,6 +246,12 @@ fun AgentsConfigScreen( } } +@Suppress("FunctionNaming") +@Composable +internal fun AgentToolLabel(tool: String) { + TuiBadge(text = tool) +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun AgentCard( @@ -303,17 +304,7 @@ private fun AgentCard( Spacer(Modifier.height(Spacing.xs)) Row(horizontalArrangement = Arrangement.spacedBy(Spacing.xs)) { agent.tools.take(3).forEach { tool -> - SuggestionChip( - onClick = {}, - label = { - Text( - tool, - style = MaterialTheme.typography.labelSmall - ) - }, - shape = RectangleShape, - modifier = Modifier.height(Sizing.iconLg) - ) + AgentToolLabel(tool) } if (agent.tools.size > 3) { Text( diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/ChatSettingsScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/ChatSettingsScreen.kt index 37e1dae3..a6b03963 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/ChatSettingsScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/ChatSettingsScreen.kt @@ -18,12 +18,12 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewModelScope import dev.blazelight.p4oc.R import dev.blazelight.p4oc.core.datastore.ChatSettings @@ -44,7 +44,7 @@ fun ChatSettingsScreen( viewModel: ChatSettingsViewModel = koinViewModel(), onNavigateBack: () -> Unit, ) { - val settings by viewModel.settings.collectAsState() + val settings by viewModel.settings.collectAsStateWithLifecycle() val theme = LocalOpenCodeTheme.current Scaffold( diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/ConnectionSettingsScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/ConnectionSettingsScreen.kt index c64bd52c..0fbba51b 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/ConnectionSettingsScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/ConnectionSettingsScreen.kt @@ -1,8 +1,8 @@ package dev.blazelight.p4oc.ui.screens.settings -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* @@ -130,7 +130,12 @@ private fun ConnectionSwitch( modifier = Modifier .fillMaxWidth() .alpha(contentAlpha) - .clickable(role = Role.Button, enabled = enabled) { onCheckedChange(!checked) } + .toggleable( + value = checked, + enabled = enabled, + role = Role.Switch, + onValueChange = onCheckedChange, + ) .padding(horizontal = Spacing.lg, vertical = Spacing.mdLg), horizontalArrangement = Arrangement.spacedBy(Spacing.lg), verticalAlignment = Alignment.CenterVertically @@ -159,7 +164,7 @@ private fun ConnectionSwitch( } TuiSwitch( checked = checked, - onCheckedChange = { onCheckedChange(!checked) }, + onCheckedChange = null, enabled = enabled, modifier = Modifier.testTag(testTag) ) diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/ModelControlsScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/ModelControlsScreen.kt index a43af60c..4053c953 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/ModelControlsScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/ModelControlsScreen.kt @@ -1,3 +1,5 @@ +@file:Suppress("ImportOrdering") + package dev.blazelight.p4oc.ui.screens.settings import androidx.compose.foundation.horizontalScroll @@ -5,6 +7,8 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material3.* @@ -13,17 +17,23 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.font.FontWeight import androidx.lifecycle.ViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewModelScope import dev.blazelight.p4oc.R import dev.blazelight.p4oc.core.network.ApiResult -import dev.blazelight.p4oc.core.network.ConnectionManager import dev.blazelight.p4oc.core.network.safeApiCall import dev.blazelight.p4oc.data.remote.dto.ModelInput -import dev.blazelight.p4oc.data.remote.dto.SetActiveModelRequest +import dev.blazelight.p4oc.data.workspace.WorkspaceClient import dev.blazelight.p4oc.ui.components.TuiLoadingScreen +import dev.blazelight.p4oc.ui.components.TuiButton +import dev.blazelight.p4oc.ui.components.TuiEmptyState import dev.blazelight.p4oc.ui.components.TuiSnackbar import dev.blazelight.p4oc.ui.components.TuiTopBar import dev.blazelight.p4oc.ui.screens.chat.ModelSelectionCoordinator @@ -36,7 +46,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import org.koin.androidx.compose.koinViewModel data class ModelInfo( val id: String, @@ -56,13 +65,15 @@ data class ModelControlsState( val selectedModelId: String? = null, val isLoading: Boolean = false, val error: String? = null, + val loadFailed: Boolean = false, val searchQuery: String = "", val filterProvider: String? = null ) class ModelControlsViewModel constructor( - private val connectionManager: ConnectionManager, - private val modelSelectionCoordinator: ModelSelectionCoordinator = ModelSelectionCoordinator() + private val workspaceClient: WorkspaceClient, + private val modelSelectionCoordinator: ModelSelectionCoordinator = ModelSelectionCoordinator(), + serverConnectionRegistry: dev.blazelight.p4oc.core.network.ServerConnectionRegistry? = null, ) : ViewModel() { private val _state = MutableStateFlow(ModelControlsState()) @@ -70,18 +81,21 @@ class ModelControlsViewModel constructor( init { loadModels() + serverConnectionRegistry?.observeWorkspaceCatalogEvents( + workspaceClient, + viewModelScope, + ) { loadModels(background = true) } } - fun loadModels() { + fun loadModels() = loadModels(background = false) + + @Suppress("CyclomaticComplexMethod") + private fun loadModels(background: Boolean) { viewModelScope.launch { - _state.update { it.copy(isLoading = true) } - val api = connectionManager.getApi() ?: run { - _state.update { it.copy(isLoading = false, error = "Not connected") } - return@launch - } + if (!background) _state.update { it.copy(isLoading = true, error = null, loadFailed = false) } // Use getProviders() which returns all providers with their models // The /model endpoint returns HTML (server-side routing issue) - val result = safeApiCall { api.getProviders() } + val result = safeApiCall { workspaceClient.getProviders() } when (result) { is ApiResult.Success -> { val models = result.data.all.flatMap { provider -> @@ -99,10 +113,14 @@ class ModelControlsViewModel constructor( ) } } - _state.update { it.copy(models = models, isLoading = false) } + _state.update { it.copy(models = models, isLoading = false, error = null, loadFailed = false) } } is ApiResult.Error -> { - _state.update { it.copy(isLoading = false, error = result.message) } + if (!background) { + _state.update { + it.copy(isLoading = false, error = "Could not load models. Try again.", loadFailed = true) + } + } } } } @@ -125,33 +143,29 @@ class ModelControlsViewModel constructor( fun selectModel(modelId: String) { viewModelScope.launch { val previousModelId = _state.value.selectedModelId - val api = connectionManager.getApi() ?: run { - _state.update { it.copy(selectedModelId = previousModelId, error = "Not connected") } - return@launch - } val model = _state.value.models.find { it.id == modelId } ?: run { - _state.update { it.copy(selectedModelId = previousModelId, error = "Model not available") } + _state.update { + it.copy(selectedModelId = previousModelId, error = "Model not available", loadFailed = false) + } return@launch } - val request = SetActiveModelRequest( - model = ModelInput( - providerID = model.providerId, - modelID = model.id - ) + val selectedModel = ModelInput( + providerID = model.providerId, + modelID = model.id ) - when (val result = safeApiCall { api.setActiveModel(request) }) { + when (safeApiCall { workspaceClient.updateCurrentModel("${model.providerId}/${model.id}") }) { is ApiResult.Success -> { - if (result.data) { - _state.update { it.copy(selectedModelId = modelId, error = null) } - modelSelectionCoordinator.publishActiveModel(request.model) - } else { - _state.update { - it.copy(selectedModelId = previousModelId, error = "Failed to set active model") - } - } + _state.update { it.copy(selectedModelId = modelId, error = null, loadFailed = false) } + modelSelectionCoordinator.publishActiveModel(selectedModel) } is ApiResult.Error -> { - _state.update { it.copy(selectedModelId = previousModelId, error = result.message) } + _state.update { + it.copy( + selectedModelId = previousModelId, + error = "Could not update the model. Try again.", + loadFailed = false + ) + } } } } @@ -165,28 +179,45 @@ class ModelControlsViewModel constructor( _state.update { it.copy(filterProvider = providerId) } } + fun clearSearchAndFilter() { + _state.update { it.copy(searchQuery = "", filterProvider = null) } + } + fun clearError() { _state.update { it.copy(error = null) } } } +internal enum class ModelListContentState { MODELS, EMPTY, NO_RESULTS } + +internal fun filteredModels(state: ModelControlsState): List = state.models.filter { model -> + val matchesSearch = state.searchQuery.isBlank() || + model.name.contains(state.searchQuery, ignoreCase = true) || + model.id.contains(state.searchQuery, ignoreCase = true) + val matchesProvider = state.filterProvider == null || model.providerId == state.filterProvider + matchesSearch && matchesProvider +}.sortedByDescending { it.isFavorite } + +internal fun modelListContentState( + state: ModelControlsState, + filteredModels: List = filteredModels(state) +): ModelListContentState = when { + state.models.isEmpty() -> ModelListContentState.EMPTY + filteredModels.isEmpty() -> ModelListContentState.NO_RESULTS + else -> ModelListContentState.MODELS +} + @OptIn(ExperimentalMaterial3Api::class) +@Suppress("FunctionNaming", "LongMethod", "NoNameShadowing") @Composable fun ModelControlsScreen( - viewModel: ModelControlsViewModel = koinViewModel(), + viewModel: ModelControlsViewModel, onNavigateBack: () -> Unit ) { val state by viewModel.state.collectAsStateWithLifecycle() val filteredModels = remember(state.models, state.searchQuery, state.filterProvider) { - state.models.filter { model -> - val matchesSearch = state.searchQuery.isEmpty() || - model.name.contains(state.searchQuery, ignoreCase = true) || - model.id.contains(state.searchQuery, ignoreCase = true) - val matchesProvider = state.filterProvider == null || - model.providerId == state.filterProvider - matchesSearch && matchesProvider - }.sortedByDescending { it.isFavorite } + filteredModels(state) } val providers = remember(state.models) { @@ -240,9 +271,54 @@ fun ModelControlsScreen( if (state.isLoading) { TuiLoadingScreen() + } else if (state.loadFailed) { + TuiEmptyState( + icon = Icons.Default.ErrorOutline, + title = stringResource(R.string.models_load_failed_title), + description = stringResource(R.string.models_load_failed_description), + iconTint = theme.error, + modifier = Modifier + .fillMaxSize() + .wrapContentSize(Alignment.Center), + action = { + TuiButton(onClick = viewModel::loadModels) { + Text(stringResource(R.string.retry)) + } + } + ) + } else if (modelListContentState(state, filteredModels) == ModelListContentState.EMPTY) { + TuiEmptyState( + icon = Icons.Default.Storage, + title = stringResource(R.string.models_empty_title), + description = stringResource(R.string.models_empty_description), + modifier = Modifier + .fillMaxSize() + .wrapContentSize(Alignment.Center), + action = { + TuiButton(onClick = viewModel::loadModels) { + Text(stringResource(R.string.refresh)) + } + } + ) + } else if (modelListContentState(state, filteredModels) == ModelListContentState.NO_RESULTS) { + TuiEmptyState( + icon = Icons.Default.SearchOff, + title = stringResource(R.string.models_no_results_title), + description = stringResource(R.string.models_no_results_description), + modifier = Modifier + .fillMaxSize() + .wrapContentSize(Alignment.Center), + action = { + TuiButton(onClick = viewModel::clearSearchAndFilter) { + Text(stringResource(R.string.models_clear_search_filters)) + } + } + ) } else { LazyColumn( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .selectableGroup(), contentPadding = PaddingValues(Spacing.xl), verticalArrangement = Arrangement.spacedBy(Spacing.md) ) { @@ -293,7 +369,7 @@ fun ModelControlsScreen( } } - state.error?.let { error -> + state.error?.takeUnless { state.loadFailed }?.let { TuiSnackbar( modifier = Modifier.padding(Spacing.xl), action = { @@ -302,7 +378,7 @@ fun ModelControlsScreen( } } ) { - Text(error) + Text(stringResource(R.string.models_operation_failed)) } } } @@ -359,17 +435,30 @@ private fun ProviderFilterChips( } @OptIn(ExperimentalMaterial3Api::class) +@Suppress("FunctionNaming", "LongMethod") @Composable -private fun ModelCard( +internal fun ModelCard( model: ModelInfo, isSelected: Boolean, onSelect: () -> Unit, onToggleFavorite: () -> Unit ) { val theme = LocalOpenCodeTheme.current + val currentModelDescription = stringResource(R.string.models_current_model) + val favoriteActionDescription = stringResource( + if (model.isFavorite) R.string.cd_remove_from_favorites else R.string.cd_add_to_favorites + ) Card( - modifier = Modifier.fillMaxWidth(), - onClick = onSelect, + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = isSelected, + onClick = onSelect, + role = Role.RadioButton + ) + .semantics { + if (isSelected) stateDescription = currentModelDescription + }, colors = CardDefaults.cardColors( containerColor = if (isSelected) { theme.accent.copy(alpha = 0.2f) @@ -410,14 +499,22 @@ private fun ModelCard( Text( text = "✓", style = MaterialTheme.typography.titleMedium, - color = theme.accent + color = theme.accent, + modifier = Modifier.clearAndSetSemantics { } ) } - IconButton(onClick = onToggleFavorite) { + IconToggleButton( + checked = model.isFavorite, + onCheckedChange = { onToggleFavorite() }, + modifier = Modifier.semantics { + contentDescription = favoriteActionDescription + } + ) { Text( text = if (model.isFavorite) "★" else "☆", style = MaterialTheme.typography.titleMedium, - color = if (model.isFavorite) SemanticColors.Accent.favorite else theme.textMuted + color = if (model.isFavorite) SemanticColors.Accent.favorite else theme.textMuted, + modifier = Modifier.clearAndSetSemantics { } ) } } @@ -427,28 +524,24 @@ private fun ModelCard( horizontalArrangement = Arrangement.spacedBy(Spacing.md) ) { if (model.supportsTools) { - AssistChip( - onClick = {}, - label = { Text(stringResource(R.string.models_tools)) }, - shape = RectangleShape, - leadingIcon = { + ModelCapabilityBadge( + label = stringResource(R.string.models_tools), + icon = { Icon( Icons.Default.Build, - contentDescription = stringResource(R.string.models_tools), + contentDescription = null, modifier = Modifier.size(Sizing.iconXs) ) } ) } if (model.supportsReasoning) { - AssistChip( - onClick = {}, - label = { Text(stringResource(R.string.models_reasoning)) }, - shape = RectangleShape, - leadingIcon = { + ModelCapabilityBadge( + label = stringResource(R.string.models_reasoning), + icon = { Icon( Icons.Default.Psychology, - contentDescription = stringResource(R.string.models_reasoning), + contentDescription = null, modifier = Modifier.size(Sizing.iconXs) ) } @@ -462,7 +555,7 @@ private fun ModelCard( ) { if (model.contextLength > 0) { Text( - text = "Context: ${formatContextLength(model.contextLength)}", + text = stringResource(R.string.models_context_format, formatContextLength(model.contextLength)), style = MaterialTheme.typography.labelSmall, color = theme.textMuted ) @@ -483,6 +576,29 @@ private fun ModelCard( } } +@Suppress("FunctionNaming") +@Composable +private fun ModelCapabilityBadge( + label: String, + icon: @Composable () -> Unit +) { + val theme = LocalOpenCodeTheme.current + Surface( + color = theme.background, + contentColor = theme.textMuted, + shape = RectangleShape + ) { + Row( + modifier = Modifier.padding(horizontal = Spacing.md, vertical = Spacing.xs), + horizontalArrangement = Arrangement.spacedBy(Spacing.xs), + verticalAlignment = Alignment.CenterVertically + ) { + icon() + Text(text = label, style = MaterialTheme.typography.labelMedium) + } + } +} + private fun formatContextLength(length: Int): String = when { length >= 1_000_000 -> "${length / 1_000_000}M" length >= 1_000 -> "${length / 1_000}K" diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/NotificationSettingsScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/NotificationSettingsScreen.kt index 9196a54e..b1e703ac 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/NotificationSettingsScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/NotificationSettingsScreen.kt @@ -9,6 +9,7 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* @@ -414,7 +415,10 @@ private fun VibrationPatternDialog( Row( modifier = Modifier .fillMaxWidth() - .clickable(role = Role.Button) { + .selectable( + selected = selectedPattern == pattern, + role = Role.RadioButton, + ) { selectedPattern = pattern onPreview(pattern) } @@ -425,10 +429,7 @@ private fun VibrationPatternDialog( ) { RadioButton( selected = selectedPattern == pattern, - onClick = { - selectedPattern = pattern - onPreview(pattern) - } + onClick = null, ) Text( text = stringResource(pattern.labelRes()), diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/ProviderConfigScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/ProviderConfigScreen.kt index f061b7e1..79b23347 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/ProviderConfigScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/ProviderConfigScreen.kt @@ -1,10 +1,16 @@ +@file:Suppress("ImportOrdering") + package dev.blazelight.p4oc.ui.screens.settings +import android.content.Intent +import android.net.Uri import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -16,6 +22,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontFamily @@ -23,6 +30,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.blazelight.p4oc.R import dev.blazelight.p4oc.data.remote.dto.ModelDto +import dev.blazelight.p4oc.data.remote.dto.ProviderAuthMethodDto import dev.blazelight.p4oc.data.remote.dto.ProviderDto import dev.blazelight.p4oc.ui.components.TuiButton import dev.blazelight.p4oc.ui.components.TuiCard @@ -32,15 +40,73 @@ import dev.blazelight.p4oc.ui.theme.LocalOpenCodeTheme import dev.blazelight.p4oc.ui.theme.SemanticColors import dev.blazelight.p4oc.ui.theme.Sizing import dev.blazelight.p4oc.ui.theme.Spacing +import dev.blazelight.p4oc.ui.workspace.WorkspaceRepositoryOwner import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf @OptIn(ExperimentalMaterial3Api::class) @Composable +@Suppress("FunctionNaming", "LongMethod") fun ProviderConfigScreen( - viewModel: ProviderConfigViewModel = koinViewModel(), + workspaceOwner: WorkspaceRepositoryOwner, + viewModel: ProviderConfigViewModel = koinViewModel( + parameters = { parametersOf(workspaceOwner) }, + ), onNavigateBack: () -> Unit ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + val pendingAuthorization = uiState.pendingAuthorization + var authorizationCode by remember(pendingAuthorization) { mutableStateOf("") } + + pendingAuthorization?.let { pending -> + AlertDialog( + onDismissRequest = viewModel::dismissAuthorization, + title = { Text(stringResource(R.string.provider_auth_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm)) { + Text(pending.authorization.instructions) + if (pending.authorization.method == "code") { + OutlinedTextField( + value = authorizationCode, + onValueChange = { authorizationCode = it }, + label = { Text(stringResource(R.string.provider_auth_code)) }, + singleLine = true, + ) + } + } + }, + confirmButton = { + TuiButton( + onClick = { + viewModel.completeOAuth( + authorizationCode.takeIf { pending.authorization.method == "code" } + ) + }, + enabled = !uiState.isAuthenticating && + (pending.authorization.method != "code" || authorizationCode.isNotBlank()) + ) { + Text(stringResource(R.string.provider_auth_complete)) + } + }, + dismissButton = { + TextButton( + onClick = { + runCatching { + val uri = Uri.parse(pending.authorization.url) + if (uri.scheme.equals("https", ignoreCase = true) || + uri.scheme.equals("http", ignoreCase = true) + ) { + context.startActivity(Intent(Intent.ACTION_VIEW, uri)) + } + } + } + ) { + Text(stringResource(R.string.provider_auth_open_browser)) + } + } + ) + } val theme = LocalOpenCodeTheme.current Scaffold( @@ -133,11 +199,16 @@ fun ProviderConfigScreen( provider = provider, isExpanded = uiState.selectedProviderId == provider.id, currentModel = uiState.currentModel, + authMethods = uiState.authMethods[provider.id].orEmpty(), + isAuthenticating = uiState.isAuthenticating, onToggle = { viewModel.selectProvider( if (uiState.selectedProviderId == provider.id) "" else provider.id ) }, + onAuthenticate = { methodIndex -> + viewModel.startOAuth(provider.id, methodIndex) + }, onSelectModel = { modelId -> viewModel.setModel(provider.id, modelId) } ) } @@ -154,7 +225,14 @@ fun ProviderConfigScreen( } items(disconnectedProviders, key = { it.id }) { provider -> - DisabledProviderCard(provider = provider) + DisabledProviderCard( + provider = provider, + authMethods = uiState.authMethods[provider.id].orEmpty(), + isAuthenticating = uiState.isAuthenticating, + onAuthenticate = { methodIndex -> + viewModel.startOAuth(provider.id, methodIndex) + } + ) } } } @@ -205,11 +283,15 @@ private fun CurrentModelCard( } @Composable +@Suppress("LongParameterList", "FunctionNaming") private fun ProviderCard( provider: ProviderDto, isExpanded: Boolean, currentModel: String?, + authMethods: List, + isAuthenticating: Boolean, onToggle: () -> Unit, + onAuthenticate: (Int) -> Unit, onSelectModel: (String) -> Unit ) { val theme = LocalOpenCodeTheme.current @@ -228,44 +310,7 @@ private fun ProviderCard( ) ) { Column { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(role = Role.Button, onClick = onToggle) - .padding(Spacing.md), - horizontalArrangement = Arrangement.spacedBy(Spacing.md), - verticalAlignment = Alignment.CenterVertically - ) { - ProviderIcon(provider.name) - - Column(modifier = Modifier.weight(1f)) { - Text( - text = provider.name, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Medium, - color = theme.text - ) - Text( - text = "${provider.models.size} model${if (provider.models.size != 1) "s" else ""}", - style = MaterialTheme.typography.bodySmall, - color = theme.textMuted - ) - } - - if (isActiveProvider) { - Text( - text = "✓", - style = MaterialTheme.typography.titleMedium, - color = theme.accent - ) - } - - Text( - text = if (isExpanded) "▴" else "▾", - style = MaterialTheme.typography.titleMedium, - color = theme.textMuted - ) - } + ProviderCardHeader(provider, isActiveProvider, isExpanded, onToggle) AnimatedVisibility( visible = isExpanded, @@ -275,6 +320,7 @@ private fun ProviderCard( Column( modifier = Modifier .fillMaxWidth() + .selectableGroup() .padding(start = Spacing.md, end = Spacing.md, bottom = Spacing.md), verticalArrangement = Arrangement.spacedBy(Spacing.xs) ) { @@ -284,6 +330,15 @@ private fun ProviderCard( ) Spacer(Modifier.height(Spacing.xs)) + authMethods.withIndex().firstOrNull { it.value.type == "oauth" }?.let { oauthMethod -> + TuiButton( + onClick = { onAuthenticate(oauthMethod.index) }, + enabled = !isAuthenticating + ) { + Text(oauthMethod.value.label) + } + } + provider.models.values.sortedBy { it.name }.forEach { model -> ModelItem( model = model, @@ -297,6 +352,48 @@ private fun ProviderCard( } } +@Composable +@Suppress("FunctionNaming") +private fun ProviderCardHeader( + provider: ProviderDto, + isActiveProvider: Boolean, + isExpanded: Boolean, + onToggle: () -> Unit +) { + val theme = LocalOpenCodeTheme.current + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(role = Role.Button, onClick = onToggle) + .padding(Spacing.md), + horizontalArrangement = Arrangement.spacedBy(Spacing.md), + verticalAlignment = Alignment.CenterVertically + ) { + ProviderIcon(provider.name) + Column(modifier = Modifier.weight(1f)) { + Text( + text = provider.name, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Medium, + color = theme.text + ) + Text( + text = "${provider.models.size} model${if (provider.models.size != 1) "s" else ""}", + style = MaterialTheme.typography.bodySmall, + color = theme.textMuted + ) + } + if (isActiveProvider) { + Text(text = "✓", style = MaterialTheme.typography.titleMedium, color = theme.accent) + } + Text( + text = if (isExpanded) "▴" else "▾", + style = MaterialTheme.typography.titleMedium, + color = theme.textMuted + ) + } +} + @Composable private fun ModelItem( model: ModelDto, @@ -315,7 +412,11 @@ private fun ModelItem( }, shape = RectangleShape ) - .clickable(role = Role.Button, onClick = onClick) + .selectable( + selected = isSelected, + onClick = onClick, + role = Role.RadioButton, + ) .padding(Spacing.md), horizontalArrangement = Arrangement.spacedBy(Spacing.md), verticalAlignment = Alignment.CenterVertically @@ -399,7 +500,13 @@ private fun CapabilityChip(text: String) { } @Composable -private fun DisabledProviderCard(provider: ProviderDto) { +@Suppress("FunctionNaming") +private fun DisabledProviderCard( + provider: ProviderDto, + authMethods: List, + isAuthenticating: Boolean, + onAuthenticate: (Int) -> Unit +) { val theme = LocalOpenCodeTheme.current TuiCard( modifier = Modifier.fillMaxWidth(), @@ -427,11 +534,21 @@ private fun DisabledProviderCard(provider: ProviderDto) { ) } - Text( - text = "⊘", - style = MaterialTheme.typography.titleMedium, - color = theme.textMuted - ) + val oauthMethod = authMethods.withIndex().firstOrNull { it.value.type == "oauth" } + if (oauthMethod != null) { + TuiButton( + onClick = { onAuthenticate(oauthMethod.index) }, + enabled = !isAuthenticating + ) { + Text(oauthMethod.value.label) + } + } else { + Text( + text = "⊘", + style = MaterialTheme.typography.titleMedium, + color = theme.textMuted + ) + } } } } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/ProviderConfigViewModel.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/ProviderConfigViewModel.kt index f6242e5f..dfdf63c9 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/ProviderConfigViewModel.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/ProviderConfigViewModel.kt @@ -2,48 +2,101 @@ package dev.blazelight.p4oc.ui.screens.settings import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import dev.blazelight.p4oc.core.network.ConnectionManager +import dev.blazelight.p4oc.core.log.AppLog +import dev.blazelight.p4oc.core.network.ServerConnectionRegistry import dev.blazelight.p4oc.data.remote.dto.ModelInput +import dev.blazelight.p4oc.data.remote.dto.OAuthCallbackRequest +import dev.blazelight.p4oc.data.remote.dto.ProviderAuthAuthorizationDto +import dev.blazelight.p4oc.data.remote.dto.ProviderAuthAuthorizeRequest +import dev.blazelight.p4oc.data.remote.dto.ProviderAuthMethodDto import dev.blazelight.p4oc.data.remote.dto.ProviderDto +import dev.blazelight.p4oc.data.workspace.WorkspaceClient +import dev.blazelight.p4oc.domain.model.OpenCodeEvent import dev.blazelight.p4oc.ui.screens.chat.ModelSelectionCoordinator import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +@OptIn(FlowPreview::class) +internal fun ServerConnectionRegistry.observeWorkspaceCatalogEvents( + workspaceClient: WorkspaceClient, + scope: CoroutineScope, + mcpOnly: Boolean = false, + includeMcp: Boolean = false, + refresh: () -> Unit, +) { + scope.launch { + events(workspaceClient.workspace.server) + .filter { scoped -> + scoped.generation == workspaceClient.generation && + scoped.workspaceKey == workspaceClient.workspace.key && + if (mcpOnly) { + scoped.event is OpenCodeEvent.McpToolsChanged + } else { + scoped.event is OpenCodeEvent.ModelsRefreshed || + scoped.event is OpenCodeEvent.CatalogUpdated || + (includeMcp && scoped.event is OpenCodeEvent.McpToolsChanged) + } + } + .debounce(CATALOG_REFRESH_DEBOUNCE_MS) + .collect { refresh() } + } +} + +private const val CATALOG_REFRESH_DEBOUNCE_MS = 150L + data class ProviderConfigUiState( val isLoading: Boolean = true, val error: String? = null, val providers: List = emptyList(), val connectedProviderIds: List = emptyList(), val currentModel: String? = null, - val selectedProviderId: String? = null + val selectedProviderId: String? = null, + val authMethods: Map> = emptyMap(), + val pendingAuthorization: PendingProviderAuthorization? = null, + val isAuthenticating: Boolean = false +) + +data class PendingProviderAuthorization( + val providerId: String, + val methodIndex: Int, + val authorization: ProviderAuthAuthorizationDto ) class ProviderConfigViewModel constructor( - private val connectionManager: ConnectionManager, - private val modelSelectionCoordinator: ModelSelectionCoordinator = ModelSelectionCoordinator() + private val workspaceClient: WorkspaceClient, + private val modelSelectionCoordinator: ModelSelectionCoordinator = ModelSelectionCoordinator(), + serverConnectionRegistry: dev.blazelight.p4oc.core.network.ServerConnectionRegistry? = null, ) : ViewModel() { + private companion object { const val TAG = "ProviderConfigViewModel" } + private val _uiState = MutableStateFlow(ProviderConfigUiState()) val uiState: StateFlow = _uiState.asStateFlow() init { loadProviders() + serverConnectionRegistry?.observeWorkspaceCatalogEvents(workspaceClient, viewModelScope) { + loadProviders(background = true) + } } - fun loadProviders() { + fun loadProviders() = loadProviders(background = false) + + private fun loadProviders(background: Boolean) { viewModelScope.launch { - _uiState.update { it.copy(isLoading = true, error = null) } - val api = connectionManager.getApi() ?: run { - _uiState.update { it.copy(isLoading = false, error = "Not connected") } - return@launch - } + if (!background) _uiState.update { it.copy(isLoading = true, error = null) } try { - val providersResponse = api.getProviders() - val config = api.getConfig() + val providersResponse = workspaceClient.getProviders() + val config = workspaceClient.getConfig() + val authMethods = workspaceClient.getProviderAuthMethods() _uiState.update { state -> state.copy( @@ -51,44 +104,110 @@ class ProviderConfigViewModel constructor( providers = providersResponse.all, connectedProviderIds = providersResponse.connected, currentModel = config.model, + authMethods = authMethods, error = null ) } } catch (e: CancellationException) { throw e } catch (e: Exception) { + AppLog.e(TAG, "Failed to load providers") + if (!background) { + _uiState.update { + it.copy( + isLoading = false, + error = "Could not load providers. Check the connection and try again." + ) + } + } + } + } + } + + fun selectProvider(providerId: String) { + _uiState.update { it.copy(selectedProviderId = providerId) } + } + + fun startOAuth(providerId: String, methodIndex: Int) { + viewModelScope.launch { + _uiState.update { it.copy(isAuthenticating = true, error = null) } + try { + val authorization = workspaceClient.authorizeProvider( + providerId, + ProviderAuthAuthorizeRequest(methodIndex) + ) _uiState.update { it.copy( - isLoading = false, - error = e.message ?: "Failed to load providers" + isAuthenticating = false, + pendingAuthorization = PendingProviderAuthorization( + providerId, + methodIndex, + authorization + ) + ) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + AppLog.e(TAG, "Failed to start provider authentication") + _uiState.update { + it.copy( + isAuthenticating = false, + error = "Could not start provider authentication. Try again." ) } } } } - fun selectProvider(providerId: String) { - _uiState.update { it.copy(selectedProviderId = providerId) } + fun completeOAuth(code: String? = null) { + val pending = _uiState.value.pendingAuthorization ?: return + viewModelScope.launch { + _uiState.update { it.copy(isAuthenticating = true, error = null) } + try { + val completed = workspaceClient.completeProviderOAuth( + pending.providerId, + OAuthCallbackRequest( + method = pending.methodIndex, + code = code?.trim()?.takeIf(String::isNotEmpty) + ) + ) + check(completed) { "OAuth callback rejected" } + _uiState.update { it.copy(pendingAuthorization = null, isAuthenticating = false) } + loadProviders() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + AppLog.e(TAG, "Failed to complete provider authentication") + _uiState.update { + it.copy( + isAuthenticating = false, + error = "Provider authentication was not completed. Try again." + ) + } + } + } + } + + fun dismissAuthorization() { + _uiState.update { it.copy(pendingAuthorization = null) } } fun setModel(providerId: String, modelId: String) { viewModelScope.launch { - val api = connectionManager.getApi() ?: run { - _uiState.update { it.copy(error = "Not connected") } - return@launch - } try { - val currentConfig = api.getConfig() + val currentConfig = workspaceClient.getConfig() val newModel = "$providerId/$modelId" val updatedConfig = currentConfig.copy(model = newModel) - val savedConfig = api.updateConfig(updatedConfig) + val savedConfig = workspaceClient.updateConfig(updatedConfig) val savedModel = savedConfig.model ?: newModel _uiState.update { it.copy(currentModel = savedModel, error = null) } parseModelInput(savedModel)?.let(modelSelectionCoordinator::publishActiveModel) } catch (e: CancellationException) { throw e } catch (e: Exception) { - _uiState.update { it.copy(error = e.message ?: "Failed to set model") } + AppLog.e(TAG, "Failed to set provider model") + _uiState.update { it.copy(error = "Could not set the model. Try again.") } } } } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/SettingsScreen.kt index cb299c2b..6848e110 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/SettingsScreen.kt @@ -39,11 +39,13 @@ import org.koin.androidx.compose.koinViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable +@Suppress("CyclomaticComplexMethod", "FunctionNaming", "LongMethod", "LongParameterList") fun SettingsScreen( viewModel: SettingsViewModel = koinViewModel(), onNavigateBack: () -> Unit, onDisconnect: () -> Unit, - onProviderConfig: () -> Unit = {}, + onProviderConfig: (() -> Unit)? = null, + onModelControls: (() -> Unit)? = null, onChatSettings: () -> Unit = {}, onVisualSettings: () -> Unit = {}, onAgentsConfig: () -> Unit = {}, @@ -86,18 +88,32 @@ fun SettingsScreen( SettingsItem( icon = Icons.Default.SmartToy, - title = stringResource(R.string.settings_provider_model), - subtitle = if (isConnected) { - stringResource(R.string.settings_provider_model_desc) + title = stringResource(R.string.settings_providers), + subtitle = if (isConnected && onProviderConfig != null) { + stringResource(R.string.settings_providers_desc) } else { stringResource(R.string.settings_requires_connection) }, - onClick = if (isConnected) onProviderConfig else null, - showChevron = isConnected, - enabled = isConnected, + onClick = onProviderConfig?.takeIf { isConnected }, + showChevron = isConnected && onProviderConfig != null, + enabled = isConnected && onProviderConfig != null, testTag = "settings_provider_item" ) + SettingsItem( + icon = Icons.Default.Tune, + title = stringResource(R.string.settings_model_controls), + subtitle = if (isConnected && onModelControls != null) { + stringResource(R.string.settings_model_controls_desc) + } else { + stringResource(R.string.settings_requires_connection) + }, + onClick = onModelControls?.takeIf { isConnected }, + showChevron = isConnected && onModelControls != null, + enabled = isConnected && onModelControls != null, + testTag = "settings_model_controls_item" + ) + SettingsItem( icon = Icons.Default.Groups, title = stringResource(R.string.settings_agents), diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/SettingsViewModel.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/SettingsViewModel.kt index 1e51e61a..07697081 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/SettingsViewModel.kt @@ -4,13 +4,24 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dev.blazelight.p4oc.core.datastore.ConnectionSettings import dev.blazelight.p4oc.core.datastore.SettingsDataStore -import dev.blazelight.p4oc.core.network.ConnectionManager +import dev.blazelight.p4oc.core.network.ConnectionState +import dev.blazelight.p4oc.core.network.ServerConnectionRegistry +import dev.blazelight.p4oc.core.network.ServerUrl +import dev.blazelight.p4oc.ui.workspace.WorkspaceRepositoryOwner import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull + +sealed interface SettingsConnectionContext { + data object Global : SettingsConnectionContext + + data class Tab(val owner: WorkspaceRepositoryOwner) : SettingsConnectionContext +} class SettingsViewModel constructor( private val settingsDataStore: SettingsDataStore, - private val connectionManager: ConnectionManager + private val serverConnectionRegistry: ServerConnectionRegistry, + private val connectionContext: SettingsConnectionContext, ) : ViewModel() { private val _uiState = MutableStateFlow(SettingsUiState()) @@ -22,23 +33,13 @@ class SettingsViewModel constructor( /** Whether the app is currently connected to an OpenCode server. */ val isConnected: StateFlow = - connectionManager.connectionState - .map { it.isConnected } + connectionContext.connectionState(serverConnectionRegistry) + .map { it is ConnectionState.Connected } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) init { viewModelScope.launch { - combine( - settingsDataStore.serverUrl, - settingsDataStore.isLocalServer, - settingsDataStore.themeMode - ) { url, isLocal, theme -> - SettingsUiState( - serverUrl = url, - isLocal = isLocal, - themeMode = theme - ) - }.collect { state -> + settingsUiState().collect { state -> _uiState.value = state } } @@ -69,8 +70,44 @@ class SettingsViewModel constructor( } suspend fun disconnect() { - connectionManager.disconnect() - settingsDataStore.clearLastConnection() + val tab = connectionContext as? SettingsConnectionContext.Tab ?: return + val serverRef = tab.owner.workspace.server + if (serverConnectionRegistry.generation(serverRef) != tab.owner.generation) return + + serverConnectionRegistry.disconnect(serverRef) + val persistedEndpoint = settingsDataStore.getLastConnection()?.first?.url + ?.let(ServerUrl::endpointKey) + if (persistedEndpoint == serverRef.endpointKey) { + settingsDataStore.clearLastConnection() + } + } + + private fun settingsUiState(): Flow = when (val context = connectionContext) { + SettingsConnectionContext.Global -> combine( + settingsDataStore.serverUrl, + settingsDataStore.isLocalServer, + settingsDataStore.themeMode, + ) { url, isLocal, theme -> SettingsUiState(url, isLocal, theme) } + + is SettingsConnectionContext.Tab -> settingsDataStore.themeMode.map { theme -> + val endpoint = context.owner.workspace.server.endpointKey + SettingsUiState( + serverUrl = endpoint, + isLocal = endpoint.toHttpUrlOrNull()?.host in LOCAL_HOSTS, + themeMode = theme, + ) + } + } + + private fun SettingsConnectionContext.connectionState( + registry: ServerConnectionRegistry, + ): Flow = when (this) { + SettingsConnectionContext.Global -> flowOf(ConnectionState.Disconnected) + is SettingsConnectionContext.Tab -> registry.connectionState(owner.workspace.server, owner.generation) + } + + private companion object { + val LOCAL_HOSTS = setOf("localhost", "127.0.0.1", "::1") } } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/SkillsScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/SkillsScreen.kt index 76f046cd..68ce8f73 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/SkillsScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/SkillsScreen.kt @@ -18,8 +18,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewModelScope import dev.blazelight.p4oc.R import dev.blazelight.p4oc.core.network.ApiResult -import dev.blazelight.p4oc.core.network.ConnectionManager import dev.blazelight.p4oc.core.network.safeApiCall +import dev.blazelight.p4oc.data.workspace.WorkspaceClient import dev.blazelight.p4oc.ui.components.TuiAlertDialog import dev.blazelight.p4oc.ui.components.TuiLoadingScreen import dev.blazelight.p4oc.ui.components.TuiTextButton @@ -32,7 +32,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import org.koin.androidx.compose.koinViewModel data class SkillInfo( val name: String, @@ -55,7 +54,6 @@ data class SkillsState( data class SkillsError( val kind: SkillsErrorKind, - val detail: String? = null, ) enum class SkillsErrorKind { @@ -75,7 +73,8 @@ internal fun mcpStatusDescriptionRes(status: String): Int = when (status) { } class SkillsViewModel constructor( - private val connectionManager: ConnectionManager + private val workspaceClient: WorkspaceClient, + serverConnectionRegistry: dev.blazelight.p4oc.core.network.ServerConnectionRegistry? = null, ) : ViewModel() { private val _state = MutableStateFlow(SkillsState()) @@ -83,21 +82,19 @@ class SkillsViewModel constructor( init { loadSkills() + serverConnectionRegistry?.observeWorkspaceCatalogEvents( + workspaceClient, + viewModelScope, + mcpOnly = true, + ) { loadSkills(background = true) } } - fun loadSkills() { + fun loadSkills() = loadSkills(background = false) + + private fun loadSkills(background: Boolean) { viewModelScope.launch { - _state.update { it.copy(isLoading = true) } - val api = connectionManager.getApi() ?: run { - _state.update { - it.copy( - isLoading = false, - error = SkillsError(SkillsErrorKind.NotConnected), - ) - } - return@launch - } - val result = safeApiCall { api.getMcpStatus() } + if (!background) _state.update { it.copy(isLoading = true) } + val result = safeApiCall { workspaceClient.getMcpStatus() } when (result) { is ApiResult.Success -> { val skills = result.data.map { (name, status) -> @@ -114,11 +111,13 @@ class SkillsViewModel constructor( _state.update { it.copy(skills = skills, isLoading = false) } } is ApiResult.Error -> { - _state.update { - it.copy( - isLoading = false, - error = SkillsError(SkillsErrorKind.ApiError, result.message), - ) + if (!background) { + _state.update { + it.copy( + isLoading = false, + error = SkillsError(SkillsErrorKind.ApiError), + ) + } } } } @@ -135,9 +134,10 @@ class SkillsViewModel constructor( } @OptIn(ExperimentalMaterial3Api::class) +@Suppress("FunctionNaming", "LongMethod", "NoNameShadowing") @Composable fun SkillsScreen( - viewModel: SkillsViewModel = koinViewModel(), + viewModel: SkillsViewModel, onNavigateBack: () -> Unit ) { val state by viewModel.state.collectAsStateWithLifecycle() @@ -146,7 +146,7 @@ fun SkillsScreen( val errorMessage = state.error?.let { error -> when (error.kind) { SkillsErrorKind.NotConnected -> stringResource(R.string.skills_error_not_connected) - SkillsErrorKind.ApiError -> error.detail ?: stringResource(R.string.skills_error_generic) + SkillsErrorKind.ApiError -> stringResource(R.string.skills_error_generic) } } // Show error in snackbar @@ -347,9 +347,9 @@ private fun SkillCard( style = MaterialTheme.typography.bodySmall, color = theme.textMuted ) - skill.errorDetail?.let { detail -> + skill.errorDetail?.let { Text( - text = detail, + text = stringResource(R.string.skills_error_generic), style = MaterialTheme.typography.labelSmall, color = theme.error ) @@ -407,8 +407,8 @@ private fun SkillDetailDialog( } ) { Text(stringResource(mcpStatusDescriptionRes(skill.status))) - skill.errorDetail?.let { detail -> - Text(detail) + skill.errorDetail?.let { + Text(stringResource(R.string.skills_error_generic)) } Row( diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/VisualSettingsScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/VisualSettingsScreen.kt index 891e2254..23a9bf92 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/VisualSettingsScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/settings/VisualSettingsScreen.kt @@ -7,7 +7,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material3.* -import androidx.compose.material3.MenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuAnchorType import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -369,7 +369,7 @@ private fun ThemeSelector( trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, modifier = Modifier .fillMaxWidth() - .menuAnchor(MenuAnchorType.PrimaryNotEditable, enabled = true) + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable, enabled = true) ) ExposedDropdownMenu( diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/terminal/TerminalScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/terminal/TerminalScreen.kt index b901b9dc..b7a9c736 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/terminal/TerminalScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/terminal/TerminalScreen.kt @@ -6,12 +6,21 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.termux.view.TerminalView +import dev.blazelight.p4oc.R import dev.blazelight.p4oc.ui.components.TermuxExtraKeysBar import dev.blazelight.p4oc.ui.components.TermuxTerminalView import dev.blazelight.p4oc.ui.components.TuiLoadingIndicator +import dev.blazelight.p4oc.ui.theme.LocalOpenCodeTheme import dev.blazelight.p4oc.ui.theme.SemanticColors +import dev.blazelight.p4oc.ui.theme.Spacing import org.koin.androidx.compose.koinViewModel @Composable @@ -20,7 +29,9 @@ fun TerminalScreen( onPtyLoaded: ((ptyId: String, ptyTitle: String) -> Unit)? = null, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val snackbarHostState = remember { SnackbarHostState() } + val accessibleScreenText by viewModel.accessibleScreenText.collectAsStateWithLifecycle() + val terminalAccessibilityLabel = stringResource(R.string.terminal_accessibility_label) + val terminalReconnectingState = stringResource(R.string.terminal_accessibility_reconnecting) var terminalView by remember { mutableStateOf(null) } val currentTerminalView by rememberUpdatedState(terminalView) @@ -63,17 +74,6 @@ fun TerminalScreen( } } - // Show error in snackbar - LaunchedEffect(uiState.error) { - uiState.error?.let { error -> - snackbarHostState.showSnackbar( - message = error, - duration = SnackbarDuration.Short - ) - viewModel.clearError() - } - } - LaunchedEffect(viewModel) { viewModel.terminalInvalidations.collect { currentTerminalView?.postInvalidate() @@ -96,16 +96,25 @@ fun TerminalScreen( ) { if (uiState.isConnecting && !uiState.isConnected) { TuiLoadingIndicator( - modifier = Modifier.align(Alignment.Center) + modifier = Modifier + .align(Alignment.Center) + .semantics { + contentDescription = terminalAccessibilityLabel + stateDescription = terminalReconnectingState + liveRegion = LiveRegionMode.Polite + } ) - } else { + } else if (uiState.isConnected) { TermuxTerminalView( emulator = viewModel.getTerminalEmulator(), + accessibleScreenText = accessibleScreenText, onKeyInput = wrappedKeyInput, modifier = Modifier.fillMaxSize(), onTerminalViewReady = { view -> terminalView = view }, onTerminalSizeChanged = viewModel::onTerminalSizeChanged, ) + } else { + terminalDisconnectedState(uiState, viewModel::reconnect) } } @@ -121,11 +130,40 @@ fun TerminalScreen( modifier = Modifier.fillMaxWidth() ) } + } +} - // Snackbar host overlaid - SnackbarHost( - hostState = snackbarHostState, - modifier = Modifier.align(Alignment.BottomCenter) +@Composable +private fun BoxScope.terminalDisconnectedState(state: TerminalUiState, onRetry: () -> Unit) { + val theme = LocalOpenCodeTheme.current + val accessibilityState = when { + state.isExited -> stringResource(R.string.terminal_exited) + else -> stringResource(R.string.terminal_disconnected) + } + Column( + modifier = Modifier + .align(Alignment.Center) + .padding(Spacing.lg) + .semantics { + stateDescription = accessibilityState + liveRegion = LiveRegionMode.Polite + }, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(Spacing.sm), + ) { + Text( + text = when { + state.isExited -> stringResource(R.string.terminal_exited) + state.error != null -> state.error + else -> stringResource(R.string.terminal_disconnected) + }, + color = theme.text, + style = MaterialTheme.typography.bodyMedium, ) + if (!state.isExited) { + TextButton(onClick = onRetry) { + Text(stringResource(R.string.retry)) + } + } } } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/terminal/TerminalTranscriptStore.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/terminal/TerminalTranscriptStore.kt new file mode 100644 index 00000000..22417bce --- /dev/null +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/terminal/TerminalTranscriptStore.kt @@ -0,0 +1,135 @@ +package dev.blazelight.p4oc.ui.screens.terminal + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch + +/** + * A bounded transcript which keeps incoming frames in order without rebuilding the full transcript + * for every frame. A String is materialized only when persistence needs a snapshot. + */ +internal class BoundedTerminalTranscript( + private val maxChars: Int, + restored: String = "", +) { + private companion object { + const val CSI_FINAL_BYTE_MIN = 0x40 + const val CSI_FINAL_BYTE_MAX = 0x7e + } + + private val chunks = ArrayDeque() + private var charCount = 0 + + init { + append(restored) + } + + val size: Int get() = charCount + + fun append(chunk: String) { + if (chunk.isEmpty()) return + chunks.addLast(chunk) + charCount += chunk.length + trimToLimit() + } + + fun clear() { + chunks.clear() + charCount = 0 + } + + fun snapshot(): String = buildString(charCount) { + chunks.forEach(::append) + } + + private fun trimToLimit() { + var overflow = charCount - maxChars + while (overflow > 0 && chunks.isNotEmpty()) { + val first = chunks.removeFirst() + if (first.length <= overflow) { + charCount -= first.length + overflow -= first.length + continue + } + + val start = safeTrimStart(first, overflow) + val remainder = first.substring(start) + chunks.addFirst(remainder) + charCount -= start + overflow = charCount - maxChars + } + discardOrphanedLowSurrogate() + } + + private fun discardOrphanedLowSurrogate() { + val first = chunks.firstOrNull() ?: return + if (!Character.isLowSurrogate(first.first())) return + chunks.removeFirst() + val remainder = first.substring(1) + if (remainder.isNotEmpty()) chunks.addFirst(remainder) + charCount-- + } + + private fun safeTrimStart(value: String, requested: Int): Int { + var start = requested.coerceIn(0, value.length) + if (start in 1 until value.length && + Character.isHighSurrogate(value[start - 1]) && Character.isLowSurrogate(value[start]) + ) { + start++ + } + + // If the boundary lands inside an ANSI/VT escape sequence, discard the remainder of that + // sequence instead of restoring/rendering a syntactically broken control sequence. + val escape = value.lastIndexOf('\u001b', startIndex = (start - 1).coerceAtLeast(0)) + if (escape >= 0 && escape < start) { + val terminator = escapeSequenceEnd(value, escape) + if (terminator >= start) start = terminator + 1 + } + return start.coerceAtMost(value.length) + } + + private fun escapeSequenceEnd(value: String, escape: Int): Int { + val next = escape + 1 + return when { + next >= value.length -> -1 + value[next] != '[' -> next + else -> findCsiFinalByte(value, escape + 2) + } + } + + private fun findCsiFinalByte(value: String, start: Int): Int { + for (index in start until value.length) { + if (value[index].code in CSI_FINAL_BYTE_MIN..CSI_FINAL_BYTE_MAX) return index + } + return -1 + } +} + +@OptIn(FlowPreview::class) +internal class TerminalTranscriptPersistence( + scope: CoroutineScope, + debounceMillis: Long, + private val snapshot: () -> String, + private val persist: (String) -> Unit, +) { + private val changes = Channel(Channel.CONFLATED) + + init { + scope.launch { + changes.receiveAsFlow() + .debounce(debounceMillis) + .collect { flushNow() } + } + } + + fun changed() { + changes.trySend(Unit) + } + + fun flushNow() { + persist(snapshot()) + } +} diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/terminal/TerminalViewModel.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/terminal/TerminalViewModel.kt index 240f14e6..9e9ab73e 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/terminal/TerminalViewModel.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/terminal/TerminalViewModel.kt @@ -7,8 +7,8 @@ import androidx.lifecycle.viewModelScope import com.termux.terminal.TerminalEmulator import dev.blazelight.p4oc.core.log.AppLog import dev.blazelight.p4oc.core.network.ApiResult -import dev.blazelight.p4oc.core.network.ConnectionManager import dev.blazelight.p4oc.core.network.PtyWebSocketClient +import dev.blazelight.p4oc.core.network.ServerConnectionRegistry import dev.blazelight.p4oc.core.network.safeApiCall import dev.blazelight.p4oc.data.remote.dto.PtySizeDto import dev.blazelight.p4oc.data.remote.dto.UpdatePtyRequest @@ -16,6 +16,8 @@ import dev.blazelight.p4oc.domain.model.OpenCodeEvent import dev.blazelight.p4oc.terminal.PtyTerminalClient import dev.blazelight.p4oc.terminal.WebSocketTerminalOutput import dev.blazelight.p4oc.ui.navigation.Screen +import dev.blazelight.p4oc.ui.workspace.WorkspaceRepositoryOwner +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -24,6 +26,7 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.sample import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -34,8 +37,9 @@ import kotlinx.coroutines.launch class TerminalViewModel constructor( private val savedStateHandle: SavedStateHandle, private val context: Context, - private val connectionManager: ConnectionManager, - private val ptyWebSocket: PtyWebSocketClient + private val ptyWebSocket: PtyWebSocketClient, + private val workspaceOwner: WorkspaceRepositoryOwner, + private val serverConnectionRegistry: ServerConnectionRegistry, ) : ViewModel() { companion object { @@ -44,11 +48,15 @@ class TerminalViewModel constructor( private const val DEFAULT_COLS = 80 private const val TRANSCRIPT_ROWS = 2000 private const val RESIZE_DEBOUNCE_MS = 150L + private const val TRANSCRIPT_PERSIST_DEBOUNCE_MS = 500L private const val MAX_SAVED_TRANSCRIPT_CHARS = 64 * 1024 + internal const val MAX_ACCESSIBLE_SCREEN_CHARS = 4 * 1024 + private const val ACCESSIBLE_SCREEN_REFRESH_MS = 2_000L private const val KEY_TRANSCRIPT = "terminal_transcript" private const val KEY_TITLE = "terminal_title" private const val KEY_EXITED = "terminal_exited" - private const val KEY_RESTORED_MISSING = "terminal_restored_missing" + + private const val MAX_TITLE_CHARS = 1_024 } val ptyId: String = savedStateHandle.get(Screen.Terminal.ARG_PTY_ID) @@ -56,7 +64,7 @@ class TerminalViewModel constructor( private val _uiState = MutableStateFlow( TerminalUiState( - title = savedStateHandle[KEY_TITLE], + title = savedStateHandle.get(KEY_TITLE)?.take(MAX_TITLE_CHARS), isExited = savedStateHandle[KEY_EXITED] ?: false, ) ) @@ -65,12 +73,29 @@ class TerminalViewModel constructor( private val _terminalInvalidations = MutableSharedFlow(extraBufferCapacity = 64) val terminalInvalidations: SharedFlow = _terminalInvalidations.asSharedFlow() + private val accessibleScreenRefreshes = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + private val _accessibleScreenText = MutableStateFlow("") + val accessibleScreenText: StateFlow = _accessibleScreenText.asStateFlow() + private var emulator: TerminalEmulator? = null private var terminalOutput: WebSocketTerminalOutput? = null private var terminalClient: PtyTerminalClient? = null private var lastKnownCols = 0 private var lastKnownRows = 0 private val pendingResize = MutableStateFlow?>(null) + private val transcript = BoundedTerminalTranscript( + maxChars = MAX_SAVED_TRANSCRIPT_CHARS, + restored = savedStateHandle.get(KEY_TRANSCRIPT).orEmpty(), + ) + private val transcriptPersistence = TerminalTranscriptPersistence( + scope = viewModelScope, + debounceMillis = TRANSCRIPT_PERSIST_DEBOUNCE_MS, + snapshot = transcript::snapshot, + persist = { savedStateHandle[KEY_TRANSCRIPT] = it }, + ) fun onTerminalSizeChanged(rows: Int, cols: Int) { if (cols == lastKnownCols && rows == lastKnownRows) { @@ -95,6 +120,27 @@ class TerminalViewModel constructor( observeWebSocketOutput() observeWebSocketState() observeResizeRequests() + observeAccessibleScreen() + } + + @OptIn(kotlinx.coroutines.FlowPreview::class) + private fun observeAccessibleScreen() { + viewModelScope.launch { + accessibleScreenRefreshes + .sample(ACCESSIBLE_SCREEN_REFRESH_MS) + .collect { refreshAccessibleScreen() } + } + } + + private fun refreshAccessibleScreen() { + val currentEmulator = emulator ?: return + val visibleText = currentEmulator.screen.getSelectedText( + 0, + 0, + currentEmulator.mColumns - 1, + currentEmulator.mRows - 1, + ) + _accessibleScreenText.value = boundedVisibleTerminalText(visibleText) } @OptIn(kotlinx.coroutines.FlowPreview::class) @@ -104,59 +150,62 @@ class TerminalViewModel constructor( .filterNotNull() .debounce(RESIZE_DEBOUNCE_MS) .collect { (rows, cols) -> - val api = connectionManager.getApi() ?: return@collect + val api = serverConnectionRegistry.api( + workspaceOwner.workspace.server, + workspaceOwner.generation, + ) ?: return@collect val result = safeApiCall { api.updatePtySession( ptyId, - UpdatePtyRequest(size = PtySizeDto(rows = rows, cols = cols)) + directory = workspaceOwner.workspace.directory, + workspace = null, + request = UpdatePtyRequest(size = PtySizeDto(rows = rows, cols = cols)), ) } when (result) { is ApiResult.Success -> AppLog.d(TAG, "PTY size updated to ${cols}x$rows") - is ApiResult.Error -> AppLog.w(TAG, "Failed to update PTY size: ${result.message}") + is ApiResult.Error -> AppLog.w(TAG, "Failed to update PTY size") } } } } private fun replayRestoredTranscript() { - val transcript = savedStateHandle.get(KEY_TRANSCRIPT).orEmpty() - if (transcript.isEmpty()) return - val bytes = transcript.toByteArray() + val restored = transcript.snapshot() + if (restored.isEmpty()) return + val bytes = restored.toByteArray() emulator?.append(bytes, bytes.size) requestTerminalInvalidation() } - private fun appendAndPersist(chunk: String) { + private fun appendTranscript(chunk: String) { val bytes = chunk.toByteArray() emulator?.append(bytes, bytes.size) - val current = savedStateHandle.get(KEY_TRANSCRIPT).orEmpty() - savedStateHandle[KEY_TRANSCRIPT] = (current + chunk).takeLast(MAX_SAVED_TRANSCRIPT_CHARS) + transcript.append(chunk) + transcriptPersistence.changed() requestTerminalInvalidation() } private fun fetchPtyDetails() { viewModelScope.launch { - val api = connectionManager.getApi() ?: return@launch - val result = safeApiCall { api.listPtySessions() } + val api = serverConnectionRegistry.api( + workspaceOwner.workspace.server, + workspaceOwner.generation, + ) ?: return@launch + val result = safeApiCall { + api.getPtySession( + id = ptyId, + directory = workspaceOwner.workspace.directory, + workspace = null, + ) + } when (result) { is ApiResult.Success -> { - val pty = result.data.find { it.id == ptyId } - if (pty == null) { - savedStateHandle[KEY_RESTORED_MISSING] = true - _uiState.update { - it.copy( - error = "Terminal session is no longer available", - isConnected = false, - isConnecting = false, - ) - } - } else { - savedStateHandle[KEY_TITLE] = pty.title - _uiState.update { state -> state.copy(title = pty.title) } - } + val title = result.data.title.take(MAX_TITLE_CHARS) + savedStateHandle[KEY_TITLE] = title + _uiState.update { state -> state.copy(title = title) } } is ApiResult.Error -> { - AppLog.e(TAG, "Failed to fetch PTY details: ${result.message}") + AppLog.e(TAG, "Failed to fetch PTY details") } } } @@ -169,7 +218,7 @@ class TerminalViewModel constructor( context = context, onTextChanged = { requestTerminalInvalidation() }, onTitleChanged = { title -> - AppLog.d(TAG, "Session title changed: $title") + AppLog.d(TAG, "Session title changed") }, onSessionFinished = { AppLog.d(TAG, "Terminal session finished") @@ -185,7 +234,7 @@ class TerminalViewModel constructor( terminalOutput = WebSocketTerminalOutput( webSocket = ptyWebSocket, onTitleChanged = { _, newTitle -> - AppLog.d(TAG, "Terminal title changed: $newTitle") + AppLog.d(TAG, "Terminal title changed") }, onBell = { AppLog.d(TAG, "Terminal bell") @@ -202,14 +251,18 @@ class TerminalViewModel constructor( } private fun connectToSession() { - ptyWebSocket.connect(ptyId) + ptyWebSocket.connect( + ptyId = ptyId, + directory = workspaceOwner.workspace.directory, + workspace = null, + ) _uiState.update { it.copy(isConnecting = true) } } private fun observeWebSocketOutput() { viewModelScope.launch { ptyWebSocket.output.collect { data -> - appendAndPersist(data) + appendTranscript(data) } } } @@ -219,14 +272,14 @@ class TerminalViewModel constructor( ptyWebSocket.connectionState.collect { connectionState -> when (connectionState) { is PtyWebSocketClient.ConnectionState.Connected -> { - AppLog.d(TAG, "WebSocket connected to ${connectionState.ptyId}") - _uiState.update { it.copy(isConnected = true, isConnecting = false) } + AppLog.d(TAG, "WebSocket connected") + _uiState.update { it.copy(isConnected = true, isConnecting = false, error = null) } } is PtyWebSocketClient.ConnectionState.Error -> { - AppLog.e(TAG, "WebSocket error: ${connectionState.message}") + AppLog.e(TAG, "WebSocket error") _uiState.update { it.copy( - error = "Connection error: ${connectionState.message}", + error = "Unable to connect to this terminal", isConnected = false, isConnecting = false ) @@ -234,7 +287,13 @@ class TerminalViewModel constructor( } is PtyWebSocketClient.ConnectionState.Disconnected -> { AppLog.d(TAG, "WebSocket disconnected") - _uiState.update { it.copy(isConnected = false, isConnecting = false) } + _uiState.update { + it.copy( + isConnected = false, + isConnecting = false, + error = it.error ?: "Terminal disconnected", + ) + } } is PtyWebSocketClient.ConnectionState.Connecting -> { AppLog.d(TAG, "WebSocket connecting...") @@ -247,20 +306,37 @@ class TerminalViewModel constructor( private fun observeEvents() { viewModelScope.launch { - connectionManager.scopedEvents.collect { scopedEvent -> + serverConnectionRegistry.events(workspaceOwner.workspace.server).collect { scopedEvent -> + if ( + scopedEvent.generation != workspaceOwner.generation || + scopedEvent.workspaceKey != workspaceOwner.workspace.key + ) { + return@collect + } when (val event = scopedEvent.event) { is OpenCodeEvent.PtyUpdated -> { if (event.pty.id == ptyId) { - savedStateHandle[KEY_TITLE] = event.pty.title - _uiState.update { it.copy(title = event.pty.title) } + val title = event.pty.title.take(MAX_TITLE_CHARS) + savedStateHandle[KEY_TITLE] = title + _uiState.update { it.copy(title = title) } } } is OpenCodeEvent.PtyExited -> { if (event.id == ptyId) { val exitMessage = "\r\n[Process exited with code ${event.exitCode}]\r\n" - appendAndPersist(exitMessage) + appendTranscript(exitMessage) savedStateHandle[KEY_EXITED] = true - _uiState.update { it.copy(isExited = true) } + _uiState.update { + it.copy(isExited = true, isConnected = false, isConnecting = false, error = null) + } + } + } + is OpenCodeEvent.PtyDeleted -> { + if (event.id == ptyId) { + savedStateHandle[KEY_EXITED] = true + _uiState.update { + it.copy(isExited = true, isConnected = false, isConnecting = false, error = null) + } } } else -> {} @@ -280,11 +356,20 @@ class TerminalViewModel constructor( fun clearTerminal() { emulator?.reset() + transcript.clear() + transcriptPersistence.changed() requestTerminalInvalidation() } + fun reconnect() { + if (_uiState.value.isConnecting || _uiState.value.isExited) return + _uiState.update { it.copy(error = null, isConnected = false, isConnecting = true) } + ptyWebSocket.reconnect() + } + private fun requestTerminalInvalidation() { _terminalInvalidations.tryEmit(Unit) + accessibleScreenRefreshes.tryEmit(Unit) } fun clearError() { @@ -292,14 +377,36 @@ class TerminalViewModel constructor( } override fun onCleared() { + transcriptPersistence.flushNow() super.onCleared() - ptyWebSocket.disconnect() + ptyWebSocket.close() emulator = null terminalClient = null terminalOutput = null } } +internal fun boundedVisibleTerminalText( + visibleText: String, + maxChars: Int = TerminalViewModel.MAX_ACCESSIBLE_SCREEN_CHARS, +): String { + require(maxChars > 0) { "maxChars must be positive" } + val normalized = visibleText + .lineSequence() + .map(String::trimEnd) + .dropWhile(String::isBlank) + .toList() + .dropLastWhile(String::isBlank) + .joinToString("\n") + if (normalized.length <= maxChars) return normalized + + var start = normalized.length - maxChars + if (Character.isLowSurrogate(normalized[start]) && start > 0 && Character.isHighSurrogate(normalized[start - 1])) { + start++ + } + return normalized.substring(start) +} + data class TerminalUiState( val isConnecting: Boolean = false, val isConnected: Boolean = false, diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/MainTabScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/MainTabScreen.kt index 47be710e..b461ea2b 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/MainTabScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/MainTabScreen.kt @@ -1,4 +1,7 @@ -@file:Suppress("DEPRECATION") // LocalLifecycleOwner – platform version until lifecycle-runtime-compose upgrade +@file:Suppress( + "DEPRECATION", // LocalLifecycleOwner – platform version until lifecycle-runtime-compose upgrade + "TooManyFunctions", +) package dev.blazelight.p4oc.ui.tabs @@ -14,6 +17,7 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBars @@ -35,6 +39,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -42,7 +47,6 @@ import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf @@ -56,6 +60,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role @@ -65,6 +70,7 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.NavHostController import androidx.navigation.compose.currentBackStackEntryAsState @@ -74,15 +80,16 @@ import dev.blazelight.p4oc.core.datastore.SavedServer import dev.blazelight.p4oc.core.datastore.SettingsDataStore import dev.blazelight.p4oc.core.log.AppLog import dev.blazelight.p4oc.core.network.ApiResult -import dev.blazelight.p4oc.core.network.ConnectionManager import dev.blazelight.p4oc.core.network.ConnectionState import dev.blazelight.p4oc.core.network.ServerConnectionRegistry import dev.blazelight.p4oc.core.network.safeApiCall import dev.blazelight.p4oc.core.network.toServerRef +import dev.blazelight.p4oc.core.notification.NotificationRoute import dev.blazelight.p4oc.data.remote.dto.CreatePtyRequest import dev.blazelight.p4oc.data.remote.dto.CreateSessionRequest import dev.blazelight.p4oc.data.session.SessionRepositoryProvider import dev.blazelight.p4oc.data.session.presence +import dev.blazelight.p4oc.domain.model.OpenCodeEvent import dev.blazelight.p4oc.domain.model.SessionConnectionState import dev.blazelight.p4oc.domain.model.SessionStatus import dev.blazelight.p4oc.domain.server.ServerRef @@ -101,6 +108,7 @@ import dev.blazelight.p4oc.ui.theme.Spacing import dev.blazelight.p4oc.ui.theme.TuiShapes import dev.blazelight.p4oc.ui.workspace.WorkspaceRepositoryOwner import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import org.koin.compose.koinInject @@ -114,7 +122,6 @@ private data class SavedServerView( private data class MainTabDeps( val tabManager: TabManager, - val connectionManager: ConnectionManager, val settingsDataStore: SettingsDataStore, val serverConnectionRegistry: ServerConnectionRegistry, val sessionRepositoryProvider: SessionRepositoryProvider, @@ -133,8 +140,29 @@ private class StartWorkUiState { var pickerSearchQuery: String by mutableStateOf("") } +internal enum class PendingStartDisposition { + WaitForConnection, + Run, + SavedServerMissing, + ConnectionFailed, + ApiUnavailable, +} + +internal fun pendingStartDisposition( + savedServerExists: Boolean, + connectionState: ConnectionState?, + apiAvailable: Boolean, +): PendingStartDisposition = when { + !savedServerExists -> PendingStartDisposition.SavedServerMissing + connectionState is ConnectionState.Error -> PendingStartDisposition.ConnectionFailed + connectionState !is ConnectionState.Connected -> PendingStartDisposition.WaitForConnection + !apiAvailable -> PendingStartDisposition.ApiUnavailable + else -> PendingStartDisposition.Run +} + private val startWorkPickerSearch: @Composable (StartWorkUiState) -> Unit = { uiState -> val theme = LocalOpenCodeTheme.current + val searchDescription = stringResource(R.string.start_work_filter_workspaces) BasicTextField( value = uiState.pickerSearchQuery, onValueChange = { uiState.pickerSearchQuery = it }, @@ -144,6 +172,7 @@ private val startWorkPickerSearch: @Composable (StartWorkUiState) -> Unit = { ui modifier = Modifier .fillMaxWidth() .border(Sizing.strokeThin, theme.border, RectangleShape) + .semantics { contentDescription = searchDescription } .testTag("start_work_search_field"), decorationBox = { field -> Row( @@ -174,6 +203,7 @@ private val startWorkServerRail: @Composable ( StartWorkUiState, ) -> Unit = { groups, selectedGroup, uiState -> val theme = LocalOpenCodeTheme.current + val resources = LocalResources.current LazyRow(horizontalArrangement = Arrangement.spacedBy(Spacing.xs)) { items(groups, key = { it.server.endpointKey }) { group -> val selected = group.server.endpointKey == selectedGroup?.server?.endpointKey @@ -188,7 +218,11 @@ private val startWorkServerRail: @Composable ( uiState.pickerSearchQuery = "" } .semantics { - contentDescription = "${group.server.displayName}, ${group.targets.size - 1} workspaces" + contentDescription = resources.getString( + R.string.start_work_server_workspaces, + group.server.displayName, + group.targets.size - 1, + ) this.selected = selected } .testTag("start_work_server_${group.server.endpointKey}"), @@ -313,7 +347,6 @@ private data class MainTabContentParams( @Composable private fun rememberMainTabDeps(): MainTabDeps { val tabManager: TabManager = koinInject() - val connectionManager: ConnectionManager = koinInject() val settingsDataStore: SettingsDataStore = koinInject() val serverConnectionRegistry: ServerConnectionRegistry = koinInject() val sessionRepositoryProvider: SessionRepositoryProvider = koinInject() @@ -321,7 +354,6 @@ private fun rememberMainTabDeps(): MainTabDeps { return remember(coroutineScope) { MainTabDeps( tabManager = tabManager, - connectionManager = connectionManager, settingsDataStore = settingsDataStore, serverConnectionRegistry = serverConnectionRegistry, sessionRepositoryProvider = sessionRepositoryProvider, @@ -352,7 +384,7 @@ private val rememberScopedConnectionStates: @Composable ( ) -> Map = { savedServers, registry -> savedServers.associate { saved -> val serverRef = ServerRef.fromEndpointKey(saved.endpointKey, saved.displayName) - val state by registry.connectionState(serverRef).collectAsState() + val state by registry.connectionState(serverRef).collectAsStateWithLifecycle() saved.endpointKey to state } } @@ -377,11 +409,11 @@ private val rememberConnectSavedServer: @Composable ( } } -private val mainTabForegroundEffect: @Composable (ConnectionManager, LifecycleOwner) -> Unit = - { connectionManager, lifecycleOwner -> +private val mainTabForegroundEffect: @Composable (ServerConnectionRegistry, LifecycleOwner) -> Unit = + { serverConnectionRegistry, lifecycleOwner -> LaunchedEffect(lifecycleOwner) { lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { - connectionManager.onAppForegrounded() + serverConnectionRegistry.onAppForegrounded() } } } @@ -454,7 +486,7 @@ private fun mainTabWorkspaceOwnersEffect( val tabOwnerInputs = tabs.mapNotNull { tab -> val serverRef = tab.serverRef ?: return@mapNotNull null val workspaceKey = tab.workspaceKey ?: return@mapNotNull null - val connection by deps.serverConnectionRegistry.connection(serverRef).collectAsState() + val connection by deps.serverConnectionRegistry.connection(serverRef).collectAsStateWithLifecycle() val generation = deps.serverConnectionRegistry.generation(serverRef) TabOwnerInput(tab.id, serverRef, workspaceKey, connection != null, generation) } @@ -533,7 +565,7 @@ private fun mainTabPresenceCollection( if (sessionId != null && workspaceOwner != null) { val sessionState by workspaceOwner.sessionRepository .sessionUiState(SessionId(sessionId)) - .collectAsState() + .collectAsStateWithLifecycle() LaunchedEffect(tab.id, activeTabId, sessionState.responseCompletedToken) { if (tab.id == activeTabId) { tabMaps.readTokens[tab.id] = sessionState.responseCompletedToken @@ -546,7 +578,7 @@ private fun mainTabPresenceCollection( tabMaps.connectionStates[tab.id] = sessionState.presence(hasUnread = hasUnread) } } else { - val tabSessionState by tab.connectionState.collectAsState() + val tabSessionState by tab.connectionState.collectAsStateWithLifecycle() LaunchedEffect(tab.id, tabSessionState) { val currentState = tabSessionState if (currentState != null) { @@ -571,14 +603,20 @@ private fun rememberCloseTab( if (route != null && route.startsWith("terminal/")) { val ptyId = tabMaps.ptyIds[tabId] if (ptyId != null) { - val serverRef = deps.tabManager.tabs.value - .firstOrNull { it.id == tabId } - ?.serverRef - val api = serverRef?.let(deps.serverConnectionRegistry::api) + val owner = tabMaps.workspaceOwners[tabId] + val api = owner?.let { + deps.serverConnectionRegistry.api(it.workspace.server, it.generation) + } if (api != null) { - val result = safeApiCall { api.deletePtySession(ptyId) } + val result = safeApiCall { + api.deletePtySession( + id = ptyId, + directory = owner.workspace.directory, + workspace = null, + ) + } if (result is ApiResult.Error) { - AppLog.e(TAG, "Failed to delete PTY $ptyId: ${result.message}") + AppLog.e(TAG, "Failed to delete PTY") } } } @@ -592,25 +630,66 @@ private fun rememberCloseTab( } @Composable +@Suppress("CyclomaticComplexMethod", "LongMethod", "LongParameterList") private fun mainTabPendingStartWorkEffect( deps: MainTabDeps, uiState: StartWorkUiState, scopedConnectionStates: Map, + savedServerExists: (String) -> Boolean, + connectSavedServer: (String) -> Unit, snackbarHostState: SnackbarHostState, ) { LaunchedEffect(uiState.pendingStartWork, scopedConnectionStates) { val pending = uiState.pendingStartWork ?: return@LaunchedEffect val target = pending.first val action = pending.second - if (scopedConnectionStates[target.serverRef.endpointKey] !is ConnectionState.Connected) { - return@LaunchedEffect + val endpointKey = target.serverRef.endpointKey + val connectionState = scopedConnectionStates[endpointKey] + val api = if (connectionState is ConnectionState.Connected) { + deps.serverConnectionRegistry.api(target.serverRef) + } else { + null } - val api = deps.serverConnectionRegistry.api(target.serverRef) ?: return@LaunchedEffect + when (pendingStartDisposition(savedServerExists(endpointKey), connectionState, api != null)) { + PendingStartDisposition.WaitForConnection -> return@LaunchedEffect + PendingStartDisposition.Run -> Unit + PendingStartDisposition.SavedServerMissing -> { + uiState.pendingStartWork = null + deps.coroutineScope.launch { + snackbarHostState.showSnackbar( + message = "This saved server is no longer available. Choose a server and workspace again.", + duration = SnackbarDuration.Long, + withDismissAction = true, + ) + } + return@LaunchedEffect + } + PendingStartDisposition.ConnectionFailed, + PendingStartDisposition.ApiUnavailable, + -> { + uiState.pendingStartWork = null + deps.coroutineScope.launch { + val result = snackbarHostState.showSnackbar( + message = "Could not connect to this server. Check its settings and try again.", + actionLabel = "Retry", + duration = SnackbarDuration.Indefinite, + withDismissAction = true, + ) + if (result == SnackbarResult.ActionPerformed && savedServerExists(endpointKey)) { + uiState.pendingStartWork = target to action + connectSavedServer(endpointKey) + } + } + return@LaunchedEffect + } + } + checkNotNull(api) when (action) { StartWorkAction.NewChat -> { val result = safeApiCall { api.createSession( directory = (target.workspaceKey as? WorkspaceKey.Directory)?.value, + workspace = null, request = CreateSessionRequest(), ) } @@ -624,12 +703,29 @@ private fun mainTabPendingStartWorkEffect( focus = true, ) } - is ApiResult.Error -> snackbarHostState.showSnackbar(result.message) + is ApiResult.Error -> { + uiState.pendingStartWork = null + deps.coroutineScope.launch { + val retry = snackbarHostState.showSnackbar( + message = "Could not create the session. Check the connection and try again.", + actionLabel = "Retry", + duration = SnackbarDuration.Indefinite, + withDismissAction = true, + ) + if (retry == SnackbarResult.ActionPerformed && savedServerExists(endpointKey)) { + uiState.pendingStartWork = target to action + } + } + } } } StartWorkAction.Terminal -> { val result = safeApiCall { - api.createPtySession(createPtyRequestForWorkspace(target.workspaceKey)) + api.createPtySession( + directory = (target.workspaceKey as? WorkspaceKey.Directory)?.value, + workspace = null, + request = createPtyRequestForWorkspace(target.workspaceKey), + ) } when (result) { is ApiResult.Success -> { @@ -641,7 +737,20 @@ private fun mainTabPendingStartWorkEffect( focus = true, ) } - is ApiResult.Error -> snackbarHostState.showSnackbar(result.message) + is ApiResult.Error -> { + uiState.pendingStartWork = null + deps.coroutineScope.launch { + val retry = snackbarHostState.showSnackbar( + message = "Could not start the terminal. Check the connection and try again.", + actionLabel = "Retry", + duration = SnackbarDuration.Indefinite, + withDismissAction = true, + ) + if (retry == SnackbarResult.ActionPerformed && savedServerExists(endpointKey)) { + uiState.pendingStartWork = target to action + } + } + } } } else -> uiState.pendingStartWork = null @@ -656,10 +765,11 @@ private fun mainTabSnackbarEffects( uiState: StartWorkUiState, snackbarHostState: SnackbarHostState, ) { + val resources = LocalResources.current LaunchedEffect(showTabWarning) { if (showTabWarning) { snackbarHostState.showSnackbar( - message = "Multiple tabs may affect performance", + message = resources.getString(R.string.tabs_performance_warning), duration = SnackbarDuration.Short, ) deps.tabManager.dismissTabWarning() @@ -671,12 +781,30 @@ private fun mainTabSnackbarEffects( uiState.restoreError = null } } + LaunchedEffect(deps.serverConnectionRegistry) { + deps.serverConnectionRegistry.scopedEvents.collect { scopedEvent -> + when (val event = scopedEvent.event) { + is OpenCodeEvent.InstallationUpdateAvailable -> snackbarHostState.showSnackbar( + message = resources.getString(R.string.server_update_available, event.version), + duration = SnackbarDuration.Long, + ) + is OpenCodeEvent.InstallationUpdated -> snackbarHostState.showSnackbar( + message = resources.getString(R.string.server_updated, event.version), + duration = SnackbarDuration.Short, + ) + else -> Unit + } + } + } } object MainTabScreen { @OptIn(ExperimentalMaterial3Api::class) @Composable + @Suppress("LongMethod") operator fun invoke( + pendingNotificationRoute: StateFlow, + onNotificationRouteConsumed: (NotificationRoute) -> Unit, onDisconnect: () -> Unit, modifier: Modifier = Modifier, ) { @@ -686,12 +814,14 @@ object MainTabScreen { val lifecycleOwner = LocalLifecycleOwner.current LaunchedEffect(Unit) { deps.tabManager.ensureHomeTab(focus = false) } - mainTabForegroundEffect(deps.connectionManager, lifecycleOwner) + mainTabForegroundEffect(deps.serverConnectionRegistry, lifecycleOwner) - val tabs by deps.tabManager.tabs.collectAsState() - val activeTabId by deps.tabManager.activeTabId.collectAsState() - val showTabWarning by deps.tabManager.showTabWarning.collectAsState() - val savedServers by deps.settingsDataStore.savedServers.collectAsState(initial = emptyList()) + val tabs by deps.tabManager.tabs.collectAsStateWithLifecycle() + val activeTabId by deps.tabManager.activeTabId.collectAsStateWithLifecycle() + val showTabWarning by deps.tabManager.showTabWarning.collectAsStateWithLifecycle() + val savedServers by deps.settingsDataStore.savedServers.collectAsStateWithLifecycle( + initialValue = emptyList(), + ) val scopedConnectionStates = rememberScopedConnectionStates( savedServers, deps.serverConnectionRegistry, @@ -702,6 +832,27 @@ object MainTabScreen { val savedServerExists = rememberSavedServerExists(savedServers) val connectSavedServer = rememberConnectSavedServer(deps.serverConnectionRegistry, savedServers) + val notificationRoute by pendingNotificationRoute.collectAsStateWithLifecycle() + LaunchedEffect(notificationRoute, savedServers) { + val route = notificationRoute ?: return@LaunchedEffect + val ownedServer = findSavedServerForNotification(route, savedServers) + if (ownedServer != null) { + val existing = deps.tabManager.findTabByNotificationRoute(route) + if (existing != null) { + deps.tabManager.focusTab(existing.id) + } else { + deps.tabManager.createTab( + startRoute = Screen.Chat.createRoute(route.sessionId), + workspaceKey = route.workspaceKey, + serverRef = ServerRef.fromEndpointKey(ownedServer.endpointKey, ownedServer.displayName), + focus = true, + ) + } + } + // Missing/removed servers safely fall back to the current screen; never guess another owner. + onNotificationRouteConsumed(route) + } + mainTabRestoreEffect(deps, savedServers, uiState) savedServerConnectionEffect(deps.serverConnectionRegistry, savedServers) mainTabPersistEffect(deps, tabs, activeTabId) @@ -717,13 +868,20 @@ object MainTabScreen { val homeRepositoryStates = tabMaps.workspaceOwners.values .distinctBy { it.workspace.server.endpointKey to it.workspace.key } .map { owner -> - val state by owner.sessionRepository.state.collectAsState() + val state by owner.sessionRepository.state.collectAsStateWithLifecycle() ScopedHomeRepositoryState(owner.workspace.server, state) } val closeTab = rememberCloseTab(deps, tabMaps) val snackbarHostState = remember { SnackbarHostState() } - mainTabPendingStartWorkEffect(deps, uiState, scopedConnectionStates, snackbarHostState) + mainTabPendingStartWorkEffect( + deps, + uiState, + scopedConnectionStates, + savedServerExists, + connectSavedServer, + snackbarHostState, + ) mainTabSnackbarEffects(deps, showTabWarning, uiState, snackbarHostState) val params = MainTabContentParams( @@ -949,7 +1107,7 @@ private val mainTabEmptyContent: @Composable (MainTabContentParams, TabInstance) params.scopedConnectionStates[tab.serverRef?.endpointKey] !is ConnectionState.Connected ) { Text( - text = "Not connected to server", + text = stringResource(R.string.server_not_connected), color = theme.textMuted, modifier = Modifier.align(Alignment.Center), ) @@ -1238,6 +1396,7 @@ private fun startWorkActionRow( marker: String, onClick: () -> Unit, ) { + val actionDescription = stringResource(R.string.start_work_action_accessibility, label, description) filesWorkspaceOption( title = label, subtitle = description, @@ -1245,7 +1404,7 @@ private fun startWorkActionRow( onClick = onClick, modifier = Modifier .testTag("start_work_${marker.lowercase()}") - .semantics { contentDescription = "$label. $description" }, + .semantics { contentDescription = actionDescription }, ) } @@ -1261,6 +1420,7 @@ private fun filesWorkspaceOption( Surface( modifier = modifier .fillMaxWidth() + .heightIn(min = Sizing.minTouchTarget) .clickable(role = Role.Button, onClick = onClick), color = theme.backgroundElement, shape = TuiShapes.small, diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/NotificationRouteOwnership.kt b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/NotificationRouteOwnership.kt new file mode 100644 index 00000000..289c6197 --- /dev/null +++ b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/NotificationRouteOwnership.kt @@ -0,0 +1,9 @@ +package dev.blazelight.p4oc.ui.tabs + +import dev.blazelight.p4oc.core.datastore.SavedServer +import dev.blazelight.p4oc.core.notification.NotificationRoute + +internal fun findSavedServerForNotification( + route: NotificationRoute, + savedServers: List, +): SavedServer? = savedServers.firstOrNull { it.endpointKey == route.serverRef.endpointKey } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabBar.kt b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabBar.kt index be914dda..54f30195 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabBar.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabBar.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import dev.blazelight.p4oc.R @@ -76,7 +77,7 @@ fun TabBar( Row( modifier = Modifier .fillMaxWidth() - .height(Sizing.chipHeight) + .height(Sizing.minTouchTarget) .padding(horizontal = Spacing.xs), verticalAlignment = Alignment.CenterVertically ) { @@ -147,7 +148,10 @@ fun TabBar( // Add button IconButton( onClick = onAddClick, - modifier = Modifier.size(Sizing.iconLg).testTag("tab_bar_add_button") + modifier = Modifier + .minimumInteractiveComponentSize() + .size(Sizing.iconLg) + .testTag("tab_bar_add_button") ) { Icon( imageVector = Icons.Default.Add, @@ -175,14 +179,23 @@ private fun tabIndicator( state.isActive -> theme.backgroundElement else -> theme.background } - Surface( + Box( modifier = modifier + .minimumInteractiveComponentSize() .height(Sizing.tabHeight) - .semantics { contentDescription = state.accessibilityLabel } + .semantics { + contentDescription = state.accessibilityLabel + selected = state.isActive + } .clickable(onClick = state.onClick, role = Role.Tab), - color = backgroundColor, + contentAlignment = Alignment.Center, ) { - tabIndicatorRow(state = state, needsAttention = needsAttention) + Surface( + modifier = Modifier.height(Sizing.tabHeight), + color = backgroundColor, + ) { + tabIndicatorRow(state = state, needsAttention = needsAttention) + } } } @@ -212,8 +225,9 @@ private fun tabIndicatorRow(state: TabIndicatorState, needsAttention: Boolean) { imageVector = Icons.Default.Close, contentDescription = stringResource(R.string.cd_close_tab), modifier = Modifier - .size(Sizing.iconXs) - .clickable(onClick = state.onClose, role = Role.Button), + .size(Sizing.minTouchTarget) + .clickable(onClick = state.onClose, role = Role.Button) + .padding((Sizing.minTouchTarget - Sizing.iconXs) / 2), tint = theme.textMuted, ) } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabManager.kt b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabManager.kt index 1572632c..a4bafa11 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabManager.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabManager.kt @@ -162,6 +162,13 @@ class TabManager { return _tabs.value.find { it.sessionId == sessionId } } + fun findTabByNotificationRoute(route: dev.blazelight.p4oc.core.notification.NotificationRoute): TabInstance? = + _tabs.value.find { + it.sessionId == route.sessionId && + it.serverRef?.endpointKey == route.serverRef.endpointKey && + it.workspaceKey == route.workspaceKey + } + /** * Update a tab's session binding. * Call when navigating to/from a chat screen within a tab. @@ -213,7 +220,7 @@ class TabManager { } if (persistedTabs.isEmpty()) return null return PersistedTabState( - serverEndpointKey = persistedTabs.first().serverEndpointKey!!, + serverEndpointKey = requireNotNull(persistedTabs.first().serverEndpointKey), activeTabId = _activeTabId.value?.takeIf { activeId -> persistedTabs.any { it.id == activeId } }, tabs = persistedTabs, ) @@ -234,8 +241,16 @@ class TabManager { } val missingServerEndpointKeys = linkedSetOf() + val restoredTabIds = mutableSetOf() val restoredTabs = state.tabs.mapNotNull { persisted -> - if (persisted.id.isBlank()) return@mapNotNull null + if (persisted.id.isBlank() || persisted.id == TabInstance.HOME_TAB_ID) return@mapNotNull null + val persistedWorkspaceKey = persisted.workspaceKey ?: return@mapNotNull null + if ( + persistedWorkspaceKey.type != PersistedWorkspaceKey.Type.GLOBAL && + persistedWorkspaceKey.value == null + ) { + return@mapNotNull null + } val workspaceKey = persisted.resolvedWorkspaceKey() ?: return@mapNotNull null val serverEndpointKey = persisted.resolvedServerEndpointKey(state.serverEndpointKey) ?: return@mapNotNull null val serverRef = availableServers[serverEndpointKey] ?: fallbackServer?.takeIf { @@ -244,6 +259,7 @@ class TabManager { missingServerEndpointKeys += serverEndpointKey return@mapNotNull null } + if (!restoredTabIds.add(persisted.id)) return@mapNotNull null val route = persisted.sessionId?.let { TabChatRouteCodec.chatRoute(it) } ?: persisted.startRoute.takeIf { it.isNotBlank() } ?: Screen.Sessions.route diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabNavHost.kt b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabNavHost.kt index 240c5a42..d74a3d35 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabNavHost.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/tabs/TabNavHost.kt @@ -7,13 +7,13 @@ import androidx.compose.animation.* import androidx.compose.animation.core.tween import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavBackStackEntry import androidx.navigation.NavHostController import androidx.navigation.NavType @@ -85,16 +85,20 @@ fun TabNavHost( ) { // Read visual settings for sub-agent tab behavior val settingsDataStore: SettingsDataStore = koinInject() - val visualSettings by settingsDataStore.visualSettings.collectAsState(initial = VisualSettings()) - val savedServers by settingsDataStore.savedServers.collectAsState(initial = emptyList()) + val visualSettings by settingsDataStore.visualSettings.collectAsStateWithLifecycle( + initialValue = VisualSettings(), + ) + val savedServers by settingsDataStore.savedServers.collectAsStateWithLifecycle( + initialValue = emptyList(), + ) val serverConnectionRegistry: ServerConnectionRegistry = koinInject() val homeConnectionStates = savedServers.associate { savedServer -> val savedServerRef = ServerRef.fromEndpointKey(savedServer.endpointKey, savedServer.displayName) - val state by serverConnectionRegistry.connectionState(savedServerRef).collectAsState() + val state by serverConnectionRegistry.connectionState(savedServerRef).collectAsStateWithLifecycle() savedServer.endpointKey to state } val openSubAgentInNewTab = visualSettings.openSubAgentInNewTab - val tabs by tabManager.tabs.collectAsState() + val tabs by tabManager.tabs.collectAsStateWithLifecycle() val tab = tabs.firstOrNull { it.id == tabId } val workspaceRevision = tab?.workspaceRevision ?: 0 @@ -354,12 +358,7 @@ fun TabNavHost( ChatScreen( viewModel = koinViewModel( parameters = { - parametersOf( - workspaceViewModel.workspaceClient, - workspaceViewModel.sessionRepository, - workspaceViewModel.fileRepository, - workspaceViewModel.uploadCoordinator, - ) + parametersOf(workspaceOwner) }, ), onNavigateBack = { @@ -394,6 +393,9 @@ fun TabNavHost( navController.navigate(Screen.Chat.createRoute(subSessionId)) } }, + onProviderAuthRequired = { + navController.navigate(Screen.ProviderConfig.route) + }, onSessionLoaded = { sessionId, sessionTitle -> // Update tab's session binding tabManager.updateTabSession(tabId, sessionId, sessionTitle) @@ -413,6 +415,7 @@ fun TabNavHost( backStackEntry.destination.route ) ProjectsScreen( + workspaceClient = workspaceOwner.workspaceClient, onNavigateBack = { navController.popBackStack() }, @@ -435,6 +438,7 @@ fun TabNavHost( navArgument(Screen.Terminal.ARG_PTY_ID) { type = NavType.StringType } ) ) { backStackEntry -> + val routePtyId = requireNotNull(backStackEntry.arguments?.getString(Screen.Terminal.ARG_PTY_ID)) TouchWorkspaceViewModel( backStackEntry, navController, @@ -443,6 +447,10 @@ fun TabNavHost( backStackEntry.destination.route ) TerminalScreen( + viewModel = koinViewModel( + key = "${workspaceOwner.tabId}:${workspaceOwner.generation.value}:$routePtyId", + parameters = { parametersOf(workspaceOwner) }, + ), onPtyLoaded = { ptyId, ptyTitle -> // Update tab binding with PTY id and title tabManager.updateTabSession(tabId, ptyId, ptyTitle) @@ -568,11 +576,18 @@ fun TabNavHost( backStackEntry.destination.route ) SettingsScreen( + viewModel = koinViewModel( + key = "${workspaceOwner.tabId}:${workspaceOwner.generation.value}:settings", + parameters = { parametersOf(SettingsConnectionContext.Tab(workspaceOwner)) }, + ), onNavigateBack = { navController.popBackStack() }, onDisconnect = onDisconnect, onProviderConfig = { navController.navigate(Screen.ProviderConfig.route) }, + onModelControls = { + navController.navigate(Screen.ModelControls.route) + }, onChatSettings = { navController.navigate(Screen.ChatSettings.route) }, @@ -619,6 +634,11 @@ fun TabNavHost( backStackEntry.destination.route ) ProviderConfigScreen( + workspaceOwner = workspaceOwner, + viewModel = koinViewModel( + key = "${workspaceOwner.tabId}:${workspaceOwner.generation.value}:provider-config", + parameters = { parametersOf(workspaceOwner) }, + ), onNavigateBack = { navController.popBackStack() } ) } @@ -658,6 +678,10 @@ fun TabNavHost( backStackEntry.destination.route ) ModelControlsScreen( + viewModel = koinViewModel( + key = "${workspaceOwner.tabId}:${workspaceOwner.generation.value}:model-controls", + parameters = { parametersOf(workspaceOwner.workspaceClient) }, + ), onNavigateBack = { navController.popBackStack() } ) } @@ -671,6 +695,10 @@ fun TabNavHost( backStackEntry.destination.route ) AgentsConfigScreen( + viewModel = koinViewModel( + key = "${workspaceOwner.tabId}:${workspaceOwner.generation.value}:agents-config", + parameters = { parametersOf(workspaceOwner.workspaceClient) }, + ), onNavigateBack = { navController.popBackStack() } ) } @@ -684,6 +712,10 @@ fun TabNavHost( backStackEntry.destination.route ) SkillsScreen( + viewModel = koinViewModel( + key = "${workspaceOwner.tabId}:${workspaceOwner.generation.value}:skills", + parameters = { parametersOf(workspaceOwner.workspaceClient) }, + ), onNavigateBack = { navController.popBackStack() } ) } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/theme/Sizing.kt b/app/src/main/java/dev/blazelight/p4oc/ui/theme/Sizing.kt index e1ef5cf2..bcf84528 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/theme/Sizing.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/theme/Sizing.kt @@ -23,8 +23,8 @@ object Sizing { val iconHero: Dp = 64.dp // Empty state icons val iconHeroLg: Dp = 96.dp // Large decorative icons - // Touch targets - Android minimum is 48dp, we use 44dp for density - val minTouchTarget: Dp = 44.dp + // Touch targets - Android accessibility minimum is 48dp. + val minTouchTarget: Dp = 48.dp val touchTargetSm: Dp = 36.dp // Compact buttons (with hit area extension) // Buttons diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/workspace/WorkspaceRepositoryOwner.kt b/app/src/main/java/dev/blazelight/p4oc/ui/workspace/WorkspaceRepositoryOwner.kt index fdcbb2ac..6bc0c7f6 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/workspace/WorkspaceRepositoryOwner.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/workspace/WorkspaceRepositoryOwner.kt @@ -35,14 +35,14 @@ class WorkspaceRepositoryOwner( private var closed = false init { - AppLog.i(TAG, logPrefix("init")) + AppLog.i(TAG, "WorkspaceRepositoryOwner.init") uploadScope.launch { sessionRepository.refresh() } } - fun touch(destinationRoute: String?) { - AppLog.d(TAG) { "${logPrefix("touch")} destination=$destinationRoute" } + fun touch(@Suppress("UNUSED_PARAMETER") destinationRoute: String?) { + AppLog.d(TAG, "WorkspaceRepositoryOwner.touch") } fun close() { @@ -51,12 +51,9 @@ class WorkspaceRepositoryOwner( uploadCoordinator.cancel() uploadScope.cancel() sessionRepositoryProvider.release(workspace, generation) - AppLog.i(TAG, logPrefix("close")) + AppLog.i(TAG, "WorkspaceRepositoryOwner.close") } - private fun logPrefix(event: String): String = - "WorkspaceRepositoryOwner.$event tabId=$tabId workspaceKey=${workspace.key} server=${workspace.server.endpointKey} generation=${generation.value} identity=$identityHash" - private companion object { const val TAG = "WorkspaceRepositoryOwner" } diff --git a/app/src/main/res/drawable/ic_notification.xml b/app/src/main/res/drawable/ic_notification.xml new file mode 100644 index 00000000..9778d2a2 --- /dev/null +++ b/app/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fe412465..6b12345f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,7 +1,26 @@ OpenCode + Terminal disconnected + Terminal process exited + Interactive terminal. Terminal output is visible on screen. Activate to enter commands. + Connected and ready for input + Reconnecting to terminal + Focus terminal input + + Delegated to %1$s: %2$s + Retrying request (attempt %1$d) + Context compacted + Run aborted + Provider authentication required + The request failed temporarily. Try again. + Run failed + OpenCode server %1$s is available + OpenCode server updated to %1$s Home + Search every server, session, or workspace + %1$d sessions · %2$d workspaces + ◈ Shared Connect to Server @@ -22,6 +41,7 @@ Credentials and security TLS checks on TLS checks off + Warning: HTTP sends credentials without transport encryption. This is allowed only for a trusted loopback or private LAN IP. Authentication configured Default authentication Server actions @@ -108,8 +128,10 @@ Settings - Provider & Model - Configure AI providers and models + Providers + Connect and authenticate AI providers + Model Controls + Choose models and favorites Agents Configure AI agents Skills @@ -214,6 +236,7 @@ New with custom directory Specify a directory on the server Create session in %1$s + Open sessions for %1$s Search chat @@ -221,12 +244,14 @@ No matches Start a conversation Type a message below to begin + Load older messages Type a message… Type a message or / for commands… Send Send Stop session Attach file + Remove file %1$s Disconnected Message is empty queued @@ -285,12 +310,17 @@ Search files… Global workspace + Workspace root Copy Path Copy Name + File + Folder + %1$s, %2$s, path %3$s + %1$s, %2$s, path %3$s, Git status %4$s -- no matching files -- -- empty folder -- - Symbol search failed: %1$s - Restored path is unavailable; showing workspace root. %1$s + Could not search symbols. Check the connection and try again. + Restored path is unavailable; showing workspace root. Create New file New folder @@ -303,6 +333,7 @@ Delete item? Delete %1$s from this workspace? This cannot be undone. File operation failed + The file operation could not be completed. Check the connection and try again. Git @@ -406,6 +437,7 @@ Agents will appear here once configured on the server Tools System Prompt + Select agent. Current agent: %1$s Skills @@ -441,13 +473,29 @@ Current Model Not configured Requires API key + Authenticate provider + Open browser + Authorization code + Complete authentication + Authenticate Favorites All Models + Current model Context: %s Tools Reasoning + Could not load models + Check the server connection, then try again. + No models available + Connect or configure a provider, then refresh this list. + No matching models + Try a different search or provider filter. + Clear search and filters + Patch: %1$d file(s) + Multiple tabs may affect performance + Not connected to server Not a Git Repository @@ -462,6 +510,8 @@ Projects No Projects Projects you\'ve worked on will appear here + Could not load projects. Check the connection and try again. + Could not update model settings. Check the connection and try again. Working @@ -469,6 +519,9 @@ Retry + Could not load this folder + Check the connection and try again. + Could not refresh. Showing the last loaded files. Reset Allow Deny @@ -526,6 +579,7 @@ No commands available No matching commands subtask + Task Select Arguments (optional) Enter command arguments… @@ -567,7 +621,7 @@ Reload Review changes No changes to save. - Failed to save: %1$s + Could not save the file. Check the connection and try again. Discard unsaved changes? You have unsaved edits in this file. Going back will discard them. File changed on disk @@ -615,9 +669,11 @@ Diff Viewer No diff content to display Session Changes - No file changes in this session + No changes in this session + Unknown file %1$d files changed +%2$d -%3$d Loading changes… + Could not load session changes. Check the connection and try again. Conversation Branches @@ -846,6 +902,7 @@ uploading done failed + %1$s · could not read source unavailable Start work In @@ -861,4 +918,7 @@ Existing work Sessions Browse sessions in this exact workspace + %1$s, %2$d workspaces + %1$s. %2$s + Close server details diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml index 45845c31..b8e8c110 100644 --- a/app/src/main/res/xml/backup_rules.xml +++ b/app/src/main/res/xml/backup_rules.xml @@ -1,4 +1,5 @@ + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml index 9e2c689a..4c4e722a 100644 --- a/app/src/main/res/xml/data_extraction_rules.xml +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -2,8 +2,10 @@ + + diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml index d7b4192e..40b2b108 100644 --- a/app/src/main/res/xml/network_security_config.xml +++ b/app/src/main/res/xml/network_security_config.xml @@ -1,5 +1,10 @@ + diff --git a/app/src/test/java/dev/blazelight/p4oc/core/datastore/SavedServerRegistryTest.kt b/app/src/test/java/dev/blazelight/p4oc/core/datastore/SavedServerRegistryTest.kt index a70347ba..2cd85994 100644 --- a/app/src/test/java/dev/blazelight/p4oc/core/datastore/SavedServerRegistryTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/core/datastore/SavedServerRegistryTest.kt @@ -23,7 +23,7 @@ class SavedServerRegistryTest { } @Test - fun `merge dedupes equivalent endpoint forms by endpoint key`() { + fun `merge keeps authoritative TLS configuration when deduping endpoint forms`() { val bare = SavedServerRegistry.fromConnection( url = "https://my-host.example.com", name = "Remote", @@ -40,7 +40,7 @@ class SavedServerRegistryTest { assertEquals(1, merged.size) assertEquals("https://my-host.example.com", merged.single().endpoint) assertEquals("https://my-host.example.com:4096", merged.single().endpointKey) - assertTrue(merged.single().allowInsecure) + assertFalse(merged.single().allowInsecure) assertTrue(merged.single().pinned) } @@ -92,10 +92,33 @@ class SavedServerRegistryTest { val alpha = migrated.first { it.endpointKey == "https://alpha.example.com:4096" } assertEquals("https://alpha.example.com", alpha.endpoint) assertEquals("last-user", alpha.username) - assertTrue(alpha.allowInsecure) + assertFalse(alpha.allowInsecure) assertTrue(migrated.any { it.displayName == "Beta recent" }) } + @Test + fun `secure upsert stays secure when merged with stale insecure representations`() { + val insecure = SavedServerRegistry.fromConnection( + url = "https://alpha.example.com", + name = "Alpha", + allowInsecure = true, + pinned = true, + ) + val secure = insecure.copy(allowInsecure = false) + + val resaved = SavedServerRegistry.upsert(listOf(insecure), secure).single() + val merged = SavedServerRegistry.merge( + listOf( + resaved, + insecure.copy(endpoint = "https://alpha.example.com:4096"), + ), + ).single() + + assertFalse(resaved.allowInsecure) + assertFalse(merged.allowInsecure) + assertTrue(merged.pinned) + } + @Test fun `normalize migrates legacy generic name without changing durable endpoint id`() { val legacy = SavedServer( diff --git a/app/src/test/java/dev/blazelight/p4oc/core/datastore/SettingsDataStoreCorruptionTest.kt b/app/src/test/java/dev/blazelight/p4oc/core/datastore/SettingsDataStoreCorruptionTest.kt new file mode 100644 index 00000000..5914c671 --- /dev/null +++ b/app/src/test/java/dev/blazelight/p4oc/core/datastore/SettingsDataStoreCorruptionTest.kt @@ -0,0 +1,17 @@ +package dev.blazelight.p4oc.core.datastore + +import androidx.datastore.core.CorruptionException +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertTrue +import org.junit.Test + +class SettingsDataStoreCorruptionTest { + @Test + fun `corrupt settings are replaced with empty preferences`() = runTest { + val replacement = settingsCorruptionHandler.handleCorruption( + CorruptionException("corrupt settings"), + ) + + assertTrue(replacement.asMap().isEmpty()) + } +} diff --git a/app/src/test/java/dev/blazelight/p4oc/core/datastore/SettingsDataStoreSelectedAgentTest.kt b/app/src/test/java/dev/blazelight/p4oc/core/datastore/SettingsDataStoreSelectedAgentTest.kt new file mode 100644 index 00000000..b9aa30da --- /dev/null +++ b/app/src/test/java/dev/blazelight/p4oc/core/datastore/SettingsDataStoreSelectedAgentTest.kt @@ -0,0 +1,58 @@ +package dev.blazelight.p4oc.core.datastore + +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Test + +class SettingsDataStoreSelectedAgentTest { + @Test + fun `lookup returns stored agent and null for unknown session`() { + val stored = """{"session-1":"build","session-2":"plan"}""" + + assertEquals("build", selectedAgentForSession(stored, "session-1")) + assertNull(selectedAgentForSession(stored, "unknown")) + } + + @Test + fun `oldest selection is evicted when cap is exceeded`() { + var stored: String? = null + repeat(MAX_SESSION_AGENT_SELECTIONS + 1) { index -> + stored = updatedSessionAgentSelections(stored, "session-$index", "agent-$index") + } + + val selections = Json.decodeFromString>(checkNotNull(stored)) + assertEquals(MAX_SESSION_AGENT_SELECTIONS, selections.size) + assertFalse("session-0" in selections) + assertEquals("agent-1", selections["session-1"]) + assertEquals("agent-${MAX_SESSION_AGENT_SELECTIONS}", selections["session-$MAX_SESSION_AGENT_SELECTIONS"]) + } + + @Test + fun `updating an existing selection makes it most recent`() { + var stored: String? = null + repeat(MAX_SESSION_AGENT_SELECTIONS) { index -> + stored = updatedSessionAgentSelections(stored, "session-$index", "agent-$index") + } + + stored = updatedSessionAgentSelections(stored, "session-0", "updated") + stored = updatedSessionAgentSelections(stored, "new-session", "new-agent") + + val selections = Json.decodeFromString>(stored) + assertEquals("updated", selections["session-0"]) + assertFalse("session-1" in selections) + assertEquals("session-0", selections.keys.elementAt(selections.size - 2)) + assertEquals("new-session", selections.keys.last()) + } + + @Test + fun `malformed state recovers for lookup and next write`() { + assertNull(selectedAgentForSession("not-json", "session")) + + val recovered = updatedSessionAgentSelections("not-json", "session", "build") + + assertEquals("build", selectedAgentForSession(recovered, "session")) + assertEquals(mapOf("session" to "build"), Json.decodeFromString>(recovered)) + } +} diff --git a/app/src/test/java/dev/blazelight/p4oc/core/datastore/SettingsDataStoreUploadDirectoriesTest.kt b/app/src/test/java/dev/blazelight/p4oc/core/datastore/SettingsDataStoreUploadDirectoriesTest.kt new file mode 100644 index 00000000..de264b94 --- /dev/null +++ b/app/src/test/java/dev/blazelight/p4oc/core/datastore/SettingsDataStoreUploadDirectoriesTest.kt @@ -0,0 +1,71 @@ +package dev.blazelight.p4oc.core.datastore + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class SettingsDataStoreUploadDirectoriesTest { + @Test + fun `entries are capped and the oldest entry is evicted`() { + val current = (0 until MAX_LAST_UPLOAD_DIRECTORIES) + .associateTo(linkedMapOf()) { "workspace-$it" to "/path/$it" } + + val updated = updateLastUploadDirectories(current, "workspace-new", "/path/new") + + assertEquals(MAX_LAST_UPLOAD_DIRECTORIES, updated.size) + assertFalse(updated.containsKey("workspace-0")) + assertEquals("/path/new", updated["workspace-new"]) + } + + @Test + fun `updating an existing entry makes it most recent`() { + val current = (0 until MAX_LAST_UPLOAD_DIRECTORIES) + .associateTo(linkedMapOf()) { "workspace-$it" to "/path/$it" } + + val refreshed = updateLastUploadDirectories(current, "workspace-0", "/path/refreshed") + val updated = updateLastUploadDirectories(refreshed, "workspace-new", "/path/new") + + assertEquals("/path/refreshed", updated["workspace-0"]) + assertFalse(updated.containsKey("workspace-1")) + assertEquals("workspace-new", updated.keys.last()) + } + + @Test + fun `blank path removes an entry`() { + val current = linkedMapOf("workspace-1" to "/one", "workspace-2" to "/two") + + val updated = updateLastUploadDirectories(current, "workspace-1", " ") + + assertEquals(mapOf("workspace-2" to "/two"), updated) + } + + @Test + fun `blank workspace key leaves entries unchanged`() { + val current = linkedMapOf("workspace" to "/path") + + val updated = updateLastUploadDirectories(current, " ", "/other") + + assertSame(current, updated) + } + + @Test + fun `malformed stored data recovers as an empty map`() { + assertTrue(decodeLastUploadDirectories("not-json").isEmpty()) + } + + @Test + fun `oversized stored data retains the most recent entries`() { + val stored = (0..MAX_LAST_UPLOAD_DIRECTORIES) + .associateTo(linkedMapOf()) { "workspace-$it" to "/path/$it" } + + val decoded = decodeLastUploadDirectories(Json.encodeToString(stored)) + + assertEquals(MAX_LAST_UPLOAD_DIRECTORIES, decoded.size) + assertFalse(decoded.containsKey("workspace-0")) + assertEquals("workspace-$MAX_LAST_UPLOAD_DIRECTORIES", decoded.keys.last()) + } +} diff --git a/app/src/test/java/dev/blazelight/p4oc/core/network/ConnectionManagerLoggingTest.kt b/app/src/test/java/dev/blazelight/p4oc/core/network/ConnectionManagerLoggingTest.kt new file mode 100644 index 00000000..972f69cb --- /dev/null +++ b/app/src/test/java/dev/blazelight/p4oc/core/network/ConnectionManagerLoggingTest.kt @@ -0,0 +1,119 @@ +package dev.blazelight.p4oc.core.network + +import dev.blazelight.p4oc.core.datastore.SettingsDataStore +import dev.blazelight.p4oc.data.remote.mapper.EventMapper +import dev.blazelight.p4oc.data.remote.mapper.MessageMapper +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ConnectionManagerLoggingTest { + + @Test + fun `authenticated client never follows redirects`() { + val client = manager.buildBaseOkHttpClient( + ServerConfig(url = "https://opencode.test", username = "user"), + password = "secret", + ) + + assertFalse(client.followRedirects) + assertFalse(client.followSslRedirects) + } + + private val dispatcher = StandardTestDispatcher() + private val json = Json { ignoreUnknownKeys = true } + private lateinit var manager: ConnectionManager + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + manager = ConnectionManager( + json = json, + eventMapper = EventMapper(json, MessageMapper(json)), + settingsDataStore = mockk(), + ) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `debug diagnostics never log provider auth or oauth bodies`() { + val logLines = mutableListOf() + val loggingInterceptor = manager.createDiagnosticLoggingInterceptor( + debugLoggingEnabled = true, + logger = { logLines += it }, + ) + val client = OkHttpClient.Builder() + .addInterceptor(loggingInterceptor) + .addInterceptor { chain -> + Response.Builder() + .request(chain.request()) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("OK") + .body("{\"access\":\"response-access-token\"}".toResponseBody(JSON)) + .build() + } + .build() + + listOf( + "/provider/openai/auth" to "{\"apiKey\":\"request-api-secret\"}", + "/provider/github/auth/callback" to "{\"code\":\"request-oauth-code\"}", + ).forEach { (path, requestJson) -> + val request = Request.Builder() + .url("https://opencode.test$path") + .header("Authorization", "Bearer request-header-token") + .post(requestJson.toRequestBody(JSON)) + .build() + + client.newCall(request).execute().use { response -> + // Consume the body so logging behavior cannot depend on the caller ignoring it. + response.body.string() + } + } + + val output = logLines.joinToString("\n") + assertTrue(output.contains("POST https://opencode.test/provider/openai/auth")) + assertTrue(output.contains("Authorization: ██")) + assertFalse(output.contains("request-api-secret")) + assertFalse(output.contains("request-oauth-code")) + assertFalse(output.contains("request-header-token")) + assertFalse(output.contains("response-access-token")) + } + + @Test + fun `release diagnostics are disabled`() { + val logLines = mutableListOf() + val interceptor = manager.createDiagnosticLoggingInterceptor( + debugLoggingEnabled = false, + logger = { logLines += it }, + ) + + assertTrue(logLines.isEmpty()) + assertTrue(interceptor.level == okhttp3.logging.HttpLoggingInterceptor.Level.NONE) + } + + private companion object { + val JSON = "application/json".toMediaType() + } +} diff --git a/app/src/test/java/dev/blazelight/p4oc/core/network/MdnsDiscoveryManagerSeedTest.kt b/app/src/test/java/dev/blazelight/p4oc/core/network/MdnsDiscoveryManagerSeedTest.kt index ab418382..66c1596f 100644 --- a/app/src/test/java/dev/blazelight/p4oc/core/network/MdnsDiscoveryManagerSeedTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/core/network/MdnsDiscoveryManagerSeedTest.kt @@ -1,13 +1,41 @@ package dev.blazelight.p4oc.core.network +import okhttp3.Authenticator import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNull +import org.junit.Assert.assertSame import org.junit.Assert.assertTrue import org.junit.Test class MdnsDiscoveryManagerSeedTest { + @Test + fun `seed probe client disables redirect and credential challenges`() { + val client = buildSeedProbeClient(allowInsecure = false) + + assertFalse(client.followRedirects) + assertFalse(client.followSslRedirects) + assertSame(Authenticator.NONE, client.authenticator) + assertSame(Authenticator.NONE, client.proxyAuthenticator) + } + + @Test + fun `seed probe does not trust redirect responses`() { + assertFalse(300.isOpenCodeSeedResponse()) + assertFalse(301.isOpenCodeSeedResponse()) + assertFalse(302.isOpenCodeSeedResponse()) + assertFalse(307.isOpenCodeSeedResponse()) + assertFalse(308.isOpenCodeSeedResponse()) + } + + @Test + fun `seed probe recognizes authenticated OpenCode server`() { + assertTrue(200.isOpenCodeSeedResponse()) + assertTrue(401.isOpenCodeSeedResponse()) + assertFalse(403.isOpenCodeSeedResponse()) + assertFalse(404.isOpenCodeSeedResponse()) + } @Test fun `normalizeSeed http host defaults 4096`() { @@ -45,6 +73,11 @@ class MdnsDiscoveryManagerSeedTest { assertNull(normalizeSeed(DiscoverySeed("ftp://example.com"))) } + @Test + fun `normalizeSeed rejects user info credentials`() { + assertNull(normalizeSeed(DiscoverySeed("http://user:secret@example.com"))) + } + @Test fun `normalizeSeed preserves ipv6 brackets and path`() { val normalized = normalizeSeed(DiscoverySeed("http://[2001:db8::1]/foo")) @@ -78,7 +111,7 @@ class MdnsDiscoveryManagerSeedTest { } @Test - fun `mergeDiscoveredServer keeps mdns over seed for same url`() { + fun `mergeDiscoveredServer does not let insecure seed downgrade strict mdns identity`() { val existing = listOf( DiscoveredServer( serviceName = "opencode-local", @@ -99,11 +132,11 @@ class MdnsDiscoveryManagerSeedTest { val merged = mergeDiscoveredServer(existing, incoming) - assertEquals(existing.first().copy(allowInsecure = true), merged.single()) + assertEquals(existing.single(), merged.single()) } @Test - fun `mergeDiscoveredServer replaces seed with mdns for same url and preserves allowInsecure`() { + fun `mergeDiscoveredServer replaces seed with mdns and preserves explicit insecure choice`() { val existing = listOf( DiscoveredServer( serviceName = "seed:example.com:4096", @@ -128,6 +161,28 @@ class MdnsDiscoveryManagerSeedTest { assertEquals(listOf(incoming.copy(allowInsecure = true)), merged) } + @Test + fun `mergeDiscoveredServer same-source duplicate preserves strict TLS choice`() { + val existing = listOf( + DiscoveredServer( + serviceName = "seed:example.com:4096", + host = "example.com", + port = 4096, + url = "https://example.com:4096", + source = DiscoverySource.SEED, + allowInsecure = false, + ) + ) + val incoming = existing.single().copy( + serviceName = "seed:example.com:4096-duplicate", + allowInsecure = true, + ) + + val merged = mergeDiscoveredServer(existing, incoming) + + assertEquals(incoming.copy(allowInsecure = false), merged.single()) + } + @Test fun `mergeDiscoveredServer replaces same-source duplicate`() { val existing = listOf( diff --git a/app/src/test/java/dev/blazelight/p4oc/core/network/OpenCodeApiPtyContractTest.kt b/app/src/test/java/dev/blazelight/p4oc/core/network/OpenCodeApiPtyContractTest.kt new file mode 100644 index 00000000..732bd532 --- /dev/null +++ b/app/src/test/java/dev/blazelight/p4oc/core/network/OpenCodeApiPtyContractTest.kt @@ -0,0 +1,66 @@ +package dev.blazelight.p4oc.core.network + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.PUT +import retrofit2.http.Path +import retrofit2.http.Query + +class OpenCodeApiPtyContractTest { + @Test + fun `PTY endpoints match upstream methods and paths`() { + assertEndpoint("listPtySessions", GET::class.java, "pty") + assertEndpoint("createPtySession", POST::class.java, "pty") + assertEndpoint("getPtySession", GET::class.java, "pty/{id}") + assertEndpoint("updatePtySession", PUT::class.java, "pty/{id}") + assertEndpoint("deletePtySession", DELETE::class.java, "pty/{id}") + } + + @Test + fun `PTY endpoints declare explicit directory and workspace query scope`() { + val methods = OpenCodeApi::class.java.declaredMethods.filter { "PtySession" in it.name } + + methods.forEach { method -> + val parameterAnnotations = method.parameterAnnotations.flatten() + assertEquals( + listOf("directory", "workspace"), + parameterAnnotations.filterIsInstance().map(Query::value), + ) + } + } + + @Test + fun `PTY update keeps encoded path id separate from body and query scope`() { + val method = OpenCodeApi::class.java.getDeclaredMethod( + "updatePtySession", + String::class.java, + String::class.java, + String::class.java, + dev.blazelight.p4oc.data.remote.dto.UpdatePtyRequest::class.java, + kotlin.coroutines.Continuation::class.java, + ) + val annotations = method.parameterAnnotations.flatten() + + assertEquals(listOf("id"), annotations.filterIsInstance().map(Path::value)) + assertEquals(1, annotations.filterIsInstance().size) + } + + private fun assertEndpoint(name: String, annotation: Class, expectedPath: String) { + val method = OpenCodeApi::class.java.declaredMethods.single { it.name == name } + val endpoint = method.getAnnotation(annotation) + assertNotNull(endpoint) + val path = when (endpoint) { + is GET -> endpoint.value + is POST -> endpoint.value + is PUT -> endpoint.value + is DELETE -> endpoint.value + else -> error("Unsupported endpoint annotation") + } + assertEquals(expectedPath, path) + } +} diff --git a/app/src/test/java/dev/blazelight/p4oc/core/network/OpenCodeEventSourceTest.kt b/app/src/test/java/dev/blazelight/p4oc/core/network/OpenCodeEventSourceTest.kt index 894884c5..5bd9fc5f 100644 --- a/app/src/test/java/dev/blazelight/p4oc/core/network/OpenCodeEventSourceTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/core/network/OpenCodeEventSourceTest.kt @@ -7,8 +7,10 @@ import dev.blazelight.p4oc.domain.model.OpenCodeEvent import io.mockk.every import io.mockk.mockkObject import io.mockk.unmockkObject +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest @@ -18,8 +20,11 @@ import kotlinx.serialization.json.Json import okhttp3.OkHttpClient import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test +import java.util.concurrent.atomic.AtomicInteger @OptIn(ExperimentalCoroutinesApi::class) class OpenCodeEventSourceTest { @@ -77,7 +82,7 @@ class OpenCodeEventSourceTest { emit.invoke(source, globalPartDeltaJson(delta = index.toString()), 1L) } - withTimeout(5_000) { + withTimeout(30_000) { while (collected.size < EVENT_COUNT) delay(10) } } finally { @@ -88,6 +93,85 @@ class OpenCodeEventSourceTest { assertEquals((0 until EVENT_COUNT).map { it.toString() }, collected) } + @Test + fun `disconnect keeps event pump available but shutdown terminates it and its channel`() { + val source = createSource() + val pumpScope = source.readPrivateField("eventPumpScope") + val channel = source.readPrivateField>("eventChannel") + + source.disconnect() + + assertTrue(pumpScope.coroutineContext[Job]!!.isActive) + assertFalse(channel.isClosedForSend) + + source.shutdown() + + assertFalse(pumpScope.coroutineContext[Job]!!.isActive) + assertTrue(channel.isClosedForSend) + } + + @Test + fun `connection error handler stops retries at terminal error cap`() { + val source = createSource() + try { + source.javaClass.getDeclaredField("generation") + .apply { isAccessible = true } + .setLong(source, 1L) + source.readPrivateField("consecutiveErrors").set(MAX_ERRORS) + + val actionMethod = source.javaClass.getDeclaredMethod( + "connectionErrorAction", + Throwable::class.java, + Long::class.javaPrimitiveType, + ).apply { isAccessible = true } + + source.readPrivateField("consecutiveErrors").set(MAX_ERRORS - 1) + val retryAction = actionMethod.invoke(source, IllegalStateException("offline"), 1L) + + source.readPrivateField("consecutiveErrors").set(MAX_ERRORS) + val terminalAction = actionMethod.invoke(source, IllegalStateException("offline"), 1L) + + assertEquals("PROCEED", retryAction.toString()) + assertEquals("SHUTDOWN", terminalAction.toString()) + } finally { + source.shutdown() + } + } + + @Test + fun `oversized event data is rejected before JSON decoding`() { + val source = createSource() + try { + val emit = source.javaClass.getDeclaredMethod( + "parseAndEmitEvent", + String::class.java, + Long::class.javaPrimitiveType, + ).apply { isAccessible = true } + + emit.invoke(source, "x".repeat(OpenCodeEventSource.MAX_EVENT_DATA_CHARS + 1), 1L) + + io.mockk.verify(exactly = 1) { + AppLog.w(any(), match { it.startsWith("Rejecting oversized SSE event") }) + } + io.mockk.verify(exactly = 0) { + AppLog.e(any(), match { it.startsWith("Failed to parse event") }, any()) + } + } finally { + source.shutdown() + } + } + + private fun createSource() = OpenCodeEventSource( + okHttpClient = OkHttpClient(), + json = json, + baseUrl = "http://127.0.0.1:1", + eventMapper = EventMapper(json, MessageMapper(json)), + ) + + @Suppress("UNCHECKED_CAST") + private fun OpenCodeEventSource.readPrivateField(name: String): T = + javaClass.getDeclaredField(name).apply { isAccessible = true }.get(this) as T + private fun globalPartDeltaJson(delta: String): String = """ { @@ -110,5 +194,6 @@ class OpenCodeEventSourceTest { private companion object { const val EVENT_COUNT = 300 + const val MAX_ERRORS = 15 } } diff --git a/app/src/test/java/dev/blazelight/p4oc/core/network/PtyWebSocketClientTest.kt b/app/src/test/java/dev/blazelight/p4oc/core/network/PtyWebSocketClientTest.kt new file mode 100644 index 00000000..857a0bf9 --- /dev/null +++ b/app/src/test/java/dev/blazelight/p4oc/core/network/PtyWebSocketClientTest.kt @@ -0,0 +1,34 @@ +package dev.blazelight.p4oc.core.network + +import org.junit.Assert.assertEquals +import org.junit.Test + +class PtyWebSocketClientTest { + @Test + fun `websocket URL preserves base path and encodes PTY id as one segment`() { + val url = buildPtyWebSocketUrl( + baseUrl = "https://terminal.example.com/opencode/", + ptyId = "id/with?reserved%chars", + directory = "/repo/with spaces?and=query", + workspace = null, + ) + + assertEquals( + "wss://terminal.example.com/opencode/pty/id%2Fwith%3Freserved%25chars/connect" + + "?directory=%2Frepo%2Fwith%20spaces%3Fand%3Dquery", + url, + ) + } + + @Test + fun `websocket URL omits explicit null workspace scope`() { + val url = buildPtyWebSocketUrl( + baseUrl = "http://terminal.example.com/", + ptyId = "pty-1", + directory = null, + workspace = null, + ) + + assertEquals("ws://terminal.example.com/pty/pty-1/connect", url) + } +} diff --git a/app/src/test/java/dev/blazelight/p4oc/core/network/ServerConnectionRegistryGenerationStateTest.kt b/app/src/test/java/dev/blazelight/p4oc/core/network/ServerConnectionRegistryGenerationStateTest.kt new file mode 100644 index 00000000..5355d5ce --- /dev/null +++ b/app/src/test/java/dev/blazelight/p4oc/core/network/ServerConnectionRegistryGenerationStateTest.kt @@ -0,0 +1,134 @@ +package dev.blazelight.p4oc.core.network + +import dev.blazelight.p4oc.core.datastore.SavedServerRegistry +import dev.blazelight.p4oc.core.datastore.SettingsDataStore +import dev.blazelight.p4oc.domain.server.ServerGeneration +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ServerConnectionRegistryGenerationStateTest { + @Test + fun `repeated lookup for active generation returns the same state flow`() = runTest { + val fixture = fixture(backgroundScope) + fixture.registry.connect(fixture.server) + runCurrent() + fixture.connection.value = connection(fixture.server.toServerConfig(), 1) + fixture.state.value = ConnectionState.Connected + runCurrent() + + val first = fixture.registry.connectionState(fixture.server.toServerRef(), ServerGeneration(1)) + val second = fixture.registry.connectionState(fixture.server.toServerRef(), ServerGeneration(1)) + + assertSame(first, second) + assertEquals(ConnectionState.Connected, first.value) + assertEquals(1, fixture.registry.generationStateCount()) + } + + @Test + fun `held generation flow becomes permanently stale when generation changes`() = runTest { + val fixture = fixture(backgroundScope) + fixture.registry.connect(fixture.server) + runCurrent() + fixture.connection.value = connection(fixture.server.toServerConfig(), 1) + fixture.state.value = ConnectionState.Connected + runCurrent() + val oldFlow = fixture.registry.connectionState(fixture.server.toServerRef(), ServerGeneration(1)) + + fixture.connection.value = connection(fixture.server.toServerConfig(), 2) + runCurrent() + val newFlow = fixture.registry.connectionState(fixture.server.toServerRef(), ServerGeneration(2)) + fixture.state.value = ConnectionState.Connecting + runCurrent() + + assertEquals(STALE_ERROR, oldFlow.value) + assertEquals(ConnectionState.Connecting, newFlow.value) + assertEquals(1, fixture.registry.generationStateCount()) + } + + @Test + fun `disconnect makes held generation flow stale and evicts registry entry`() = runTest { + val fixture = fixture(backgroundScope) + fixture.registry.connect(fixture.server) + runCurrent() + fixture.connection.value = connection(fixture.server.toServerConfig(), 1) + fixture.state.value = ConnectionState.Connected + runCurrent() + val heldFlow = fixture.registry.connectionState(fixture.server.toServerRef(), ServerGeneration(1)) + + fixture.registry.disconnect(fixture.server.toServerRef()) + + assertEquals(STALE_ERROR, heldFlow.value) + assertEquals(0, fixture.registry.generationStateCount()) + assertEquals(ConnectionState.Disconnected, fixture.registry.connectionState(fixture.server.toServerRef()).value) + } + + @Test + fun `repeated generations keep only the active generation entry`() = runTest { + val fixture = fixture(backgroundScope) + fixture.registry.connect(fixture.server) + runCurrent() + + repeat(50) { index -> + val generation = ServerGeneration(index.toLong() + 1) + fixture.connection.value = connection(fixture.server.toServerConfig(), generation.value) + fixture.state.value = ConnectionState.Connected + runCurrent() + fixture.registry.connectionState(fixture.server.toServerRef(), generation) + assertEquals(1, fixture.registry.generationStateCount()) + } + } + + private fun fixture(scope: CoroutineScope): Fixture { + val server = SavedServerRegistry.fromConnection("http://generation-state.example.com", "Generation") + val managerConnection = MutableStateFlow(null) + val managerState = MutableStateFlow(ConnectionState.Disconnected) + val manager = mockk(relaxed = true) { + every { connection } returns managerConnection + every { connectionState } returns managerState + } + coEvery { manager.connect(server.toServerConfig(), any()) } returns Result.failure( + IllegalStateException("unused"), + ) + val settings = mockk() + coEvery { settings.getSavedServerPassword(any()) } returns null + return Fixture( + server = server, + connection = managerConnection, + state = managerState, + registry = ServerConnectionRegistry(settings, { manager }, scope), + ) + } + + private fun connection(config: ServerConfig, generation: Long): Connection { + val eventSource = mockk { + every { directoryEvents } returns MutableSharedFlow() + } + return mockk { + every { this@mockk.config } returns config + every { this@mockk.generation } returns ServerGeneration(generation) + every { this@mockk.eventSource } returns eventSource + } + } + + private data class Fixture( + val server: dev.blazelight.p4oc.core.datastore.SavedServer, + val connection: MutableStateFlow, + val state: MutableStateFlow, + val registry: ServerConnectionRegistry, + ) + + private companion object { + val STALE_ERROR = ConnectionState.Error("Server connection generation is no longer available") + } +} diff --git a/app/src/test/java/dev/blazelight/p4oc/core/network/ServerConnectionRegistryTest.kt b/app/src/test/java/dev/blazelight/p4oc/core/network/ServerConnectionRegistryTest.kt index 7fbd2e29..da63168f 100644 --- a/app/src/test/java/dev/blazelight/p4oc/core/network/ServerConnectionRegistryTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/core/network/ServerConnectionRegistryTest.kt @@ -1,22 +1,230 @@ +@file:Suppress("ImportOrdering") + package dev.blazelight.p4oc.core.network import dev.blazelight.p4oc.core.datastore.SavedServerRegistry import dev.blazelight.p4oc.core.datastore.SettingsDataStore +import dev.blazelight.p4oc.domain.model.OpenCodeEvent +import dev.blazelight.p4oc.domain.server.ScopedEvent +import dev.blazelight.p4oc.domain.server.ServerGeneration import dev.blazelight.p4oc.domain.server.ServerRef +import dev.blazelight.p4oc.domain.server.WorkspaceKey import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation + +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest +import okhttp3.OkHttpClient import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) class ServerConnectionRegistryTest { + @Test + fun `awaited connection returns success only from registry owned exact server`() = runTest { + val server = SavedServerRegistry.fromConnection("https://owned.example.com", "Owned") + val manager = successfulManager(server) + every { manager.connection } returns MutableStateFlow( + mockk { + every { config } returns server.toServerConfig() + every { generation } returns ServerGeneration(1) + every { eventSource } returns mockk { every { directoryEvents } returns MutableSharedFlow() } + }, + ) + val registry = registryFor(backgroundScope) { manager } + + val result = registry.connectAndAwait(server, "password") + + assertTrue(result.isSuccess) + assertSame(manager.connection.value, registry.connection(server.toServerRef()).value) + coVerify(exactly = 1) { manager.connect(server.toServerConfig(), "password") } + } + + @Test + fun `replaced awaited attempt is cancelled and cannot publish stale success`() = runTest { + val server = SavedServerRegistry.fromConnection("https://replace.example.com", "Replace") + val firstStarted = CompletableDeferred() + val releaseFirst = CompletableDeferred() + val connection = mockk { + every { config } returns server.toServerConfig() + every { generation } returns ServerGeneration(2) + every { eventSource } returns mockk { every { directoryEvents } returns MutableSharedFlow() } + } + val manager = mockk(relaxed = true) + every { manager.connection } returns MutableStateFlow(connection) + every { manager.connectionState } returns MutableStateFlow(ConnectionState.Connected) + var call = 0 + coEvery { manager.connect(server.toServerConfig(), any()) } coAnswers { + call += 1 + if (call == 1) { + firstStarted.complete(Unit) + releaseFirst.await() + } + Result.success(emptyList()) + } + val registry = registryFor(backgroundScope) { manager } + + val first = async { registry.connectAndAwait(server, "first") } + firstStarted.await() + val second = async { registry.connectAndAwait(server, "second") } + runCurrent() + releaseFirst.complete(Unit) + runCurrent() + + assertTrue(first.isCancelled) + assertTrue(second.await().isSuccess) + coVerify(exactly = 1) { manager.connect(server.toServerConfig(), "first") } + coVerify(exactly = 1) { manager.connect(server.toServerConfig(), "second") } + } + + @Test + fun `terminal transport is isolated by server and rejects stale generation`() = runTest { + val alpha = SavedServerRegistry.fromConnection("http://alpha-terminal.example.com", "Alpha") + val beta = SavedServerRegistry.fromConnection("http://beta-terminal.example.com", "Beta") + val alphaClient = OkHttpClient() + val betaClient = OkHttpClient() + val eventSource = mockk { + every { directoryEvents } returns MutableSharedFlow() + } + val alphaConnection = mockk { + every { config } returns alpha.toServerConfig() + every { generation } returns ServerGeneration(11) + every { this@mockk.eventSource } returns eventSource + } + val betaConnection = mockk { + every { config } returns beta.toServerConfig() + every { generation } returns ServerGeneration(22) + every { this@mockk.eventSource } returns eventSource + } + val alphaManager = successfulManager(alpha).also { + every { it.connection } returns MutableStateFlow(alphaConnection) + every { it.currentGeneration } returns ServerGeneration(11) + every { it.authOkHttpClient } returns MutableStateFlow(alphaClient) + } + val betaManager = successfulManager(beta).also { + every { it.connection } returns MutableStateFlow(betaConnection) + every { it.currentGeneration } returns ServerGeneration(22) + every { it.authOkHttpClient } returns MutableStateFlow(betaClient) + } + val registry = registryFor(backgroundScope) { config -> + when (config.url) { + alpha.endpoint -> alphaManager + beta.endpoint -> betaManager + else -> error("unexpected config $config") + } + } + + registry.connect(alpha) + registry.connect(beta) + runCurrent() + + val alphaTransport = registry.terminalTransport(alpha.toServerRef(), ServerGeneration(11)) + val betaTransport = registry.terminalTransport(beta.toServerRef(), ServerGeneration(22)) + assertSame(alphaConnection, alphaTransport?.connection) + assertSame(alphaClient, alphaTransport?.authClient) + assertSame(betaConnection, betaTransport?.connection) + assertSame(betaClient, betaTransport?.authClient) + assertNull(registry.terminalTransport(alpha.toServerRef(), ServerGeneration(22))) + assertNull(registry.terminalTransport(beta.toServerRef(), ServerGeneration(11))) + } + + @Test + fun `two servers expose independent live events exactly once`() = runTest { + val alpha = SavedServerRegistry.fromConnection("http://alpha-events.example.com", "Alpha") + val beta = SavedServerRegistry.fromConnection("http://beta-events.example.com", "Beta") + val alphaEvents = MutableSharedFlow() + val betaEvents = MutableSharedFlow() + val alphaManager = successfulManagerWithEvents(alpha, 1, alphaEvents) + val betaManager = successfulManagerWithEvents(beta, 1, betaEvents) + val registry = registryFor(backgroundScope) { config -> + if (config.url == alpha.endpoint) alphaManager else betaManager + } + registry.connect(alpha) + registry.connect(beta) + runCurrent() + val alphaReceived = async { registry.events(alpha.toServerRef()).first() } + val betaReceived = async { registry.events(beta.toServerRef()).first() } + runCurrent() + val alphaEvent = scopedEvent(alpha.toServerRef(), 1) + val betaEvent = scopedEvent(beta.toServerRef(), 1) + + alphaEvents.emit(OpenCodeEventSource.DirectoryEvent(null, alphaEvent.event)) + betaEvents.emit(OpenCodeEventSource.DirectoryEvent(null, betaEvent.event)) + + assertEquals(alphaEvent, alphaReceived.await()) + assertEquals(betaEvent, betaReceived.await()) + } + + @Test + fun `successful probe remains connecting until manager reports connected`() = runTest { + val server = SavedServerRegistry.fromConnection("http://pending.example.com", "Pending") + val managerState = MutableStateFlow(ConnectionState.Disconnected) + val manager = mockk(relaxed = true) + every { manager.connection } returns MutableStateFlow(null) + every { manager.connectionState } returns managerState + coEvery { manager.connect(server.toServerConfig(), any()) } returns Result.success(emptyList()) + val registry = registryFor(backgroundScope) { manager } + + registry.connect(server) + runCurrent() + + assertEquals(ConnectionState.Connecting, registry.connectionState(server.toServerRef()).value) + + managerState.value = ConnectionState.Connected + runCurrent() + + assertEquals(ConnectionState.Connected, registry.connectionState(server.toServerRef()).value) + } + + @Test + fun `connection flow obtained before connect follows manager connection`() = runTest { + val server = SavedServerRegistry.fromConnection("http://flow.example.com", "Flow") + val managerConnection = MutableStateFlow(null) + val manager = mockk(relaxed = true) + every { manager.connection } returns managerConnection + every { manager.connectionState } returns MutableStateFlow(ConnectionState.Connecting) + coEvery { manager.connect(server.toServerConfig(), any()) } returns Result.success(emptyList()) + val registry = registryFor(backgroundScope) { manager } + val connectionFlow = registry.connection(server.toServerRef()) + val connection = mockk { + every { config } returns server.toServerConfig() + every { generation } returns ServerGeneration(1) + every { eventSource } returns mockk { every { directoryEvents } returns MutableSharedFlow() } + } + + registry.connect(server) + runCurrent() + managerConnection.value = connection + runCurrent() + + assertEquals(connection, connectionFlow.value) + } + + @Test + fun `local classification uses exact parsed host`() { + val localhost = SavedServerRegistry.fromConnection("http://localhost:4096", "Local") + val loopback = SavedServerRegistry.fromConnection("http://127.0.0.1:4096", "Loopback") + val attacker = SavedServerRegistry.fromConnection("http://localhost.attacker.example", "Attacker") + + assertTrue(localhost.toServerConfig().isLocal) + assertTrue(loopback.toServerConfig().isLocal) + assertFalse(attacker.toServerConfig().isLocal) + } @Test fun `two saved servers keep independent connection states`() = runTest { @@ -42,6 +250,25 @@ class ServerConnectionRegistryTest { coVerify(exactly = 1) { betaManager.connect(beta.toServerConfig(), null) } } + @Test + fun `foreground recovery reaches every owned manager exactly once`() = runTest { + val alpha = SavedServerRegistry.fromConnection("http://alpha.example.com", "Alpha") + val beta = SavedServerRegistry.fromConnection("http://beta.example.com", "Beta") + val alphaManager = successfulManager(alpha) + val betaManager = successfulManager(beta) + val registry = registryFor(backgroundScope) { config -> + if (config.url == alpha.endpoint) alphaManager else betaManager + } + registry.connect(alpha) + registry.connect(beta) + runCurrent() + + registry.onAppForegrounded() + + verify(exactly = 1) { alphaManager.onAppForegrounded() } + verify(exactly = 1) { betaManager.onAppForegrounded() } + } + @Test fun `one server failure does not overwrite another server state`() = runTest { val alpha = SavedServerRegistry.fromConnection("http://alpha.example.com", "Alpha") @@ -61,7 +288,7 @@ class ServerConnectionRegistryTest { runCurrent() assertEquals(ConnectionState.Connected, registry.connectionState(alpha.toServerRef()).value) - assertEquals(ConnectionState.Error("auth failed"), registry.connectionState(beta.toServerRef()).value) + assertEquals(ConnectionState.Error("Connection failed"), registry.connectionState(beta.toServerRef()).value) } @Test @@ -112,6 +339,97 @@ class ServerConnectionRegistryTest { coVerify(exactly = 0) { betaManager.disconnect() } } + @Test + fun `global disposed invalidates the exact emitting generation`() = runTest { + val server = SavedServerRegistry.fromConnection("http://disposed.example.com", "Disposed") + val events = MutableSharedFlow() + val manager = successfulManagerWithEvents(server, 7, events) + every { manager.disconnect(ServerGeneration(7)) } returns true + val registry = registryFor(backgroundScope) { manager } + registry.connect(server) + runCurrent() + val heldGenerationState = registry.connectionState(server.toServerRef(), ServerGeneration(7)) + + events.emit(OpenCodeEventSource.DirectoryEvent(null, OpenCodeEvent.GlobalDisposed)) + runCurrent() + + assertEquals(ConnectionState.Disconnected, registry.connectionState(server.toServerRef()).value) + assertNull(registry.connection(server.toServerRef()).value) + assertEquals( + ConnectionState.Error("Server connection generation is no longer available"), + heldGenerationState.value, + ) + verify(exactly = 1) { manager.disconnect(ServerGeneration(7)) } + } + + @Test + fun `stale global disposed cannot invalidate replacement generation`() = runTest { + val server = SavedServerRegistry.fromConnection("http://replacement.example.com", "Replacement") + val manager = successfulManager(server) + every { manager.currentGeneration } returns ServerGeneration(8) + every { manager.disconnect(ServerGeneration(7)) } returns false + val registry = registryFor(backgroundScope) { manager } + registry.connect(server) + runCurrent() + + val invalidated = registry.invalidateGeneration(server.toServerRef(), ServerGeneration(7)) + + assertFalse(invalidated) + assertEquals(ConnectionState.Connected, registry.connectionState(server.toServerRef()).value) + assertSame(manager.connection.value, registry.connection(server.toServerRef()).value) + verify(exactly = 1) { manager.disconnect(ServerGeneration(7)) } + verify(exactly = 0) { manager.disconnect() } + } + + @Test + fun `directory scoped server disposal remains an event without disconnecting generation`() = runTest { + val server = SavedServerRegistry.fromConnection("http://directory-disposed.example.com", "Directory") + val events = MutableSharedFlow() + val manager = successfulManagerWithEvents(server, 3, events) + val registry = registryFor(backgroundScope) { manager } + registry.connect(server) + runCurrent() + val received = async { registry.events(server.toServerRef()).first() } + runCurrent() + + events.emit( + OpenCodeEventSource.DirectoryEvent( + "/workspace", + OpenCodeEvent.ServerInstanceDisposed("/workspace"), + ), + ) + assertTrue(received.await().event is OpenCodeEvent.ServerInstanceDisposed) + + assertEquals(ConnectionState.Connected, registry.connectionState(server.toServerRef()).value) + verify(exactly = 0) { manager.disconnect(ServerGeneration(3)) } + } + + @Test + fun `disconnect cancels an in flight connection attempt`() = runTest { + val server = SavedServerRegistry.fromConnection("http://slow.example.com", "Slow") + val manager = mockk(relaxed = true) + val cancelled = CompletableDeferred() + every { manager.connection } returns MutableStateFlow(null) + every { manager.connectionState } returns MutableStateFlow(ConnectionState.Disconnected) + coEvery { manager.connect(server.toServerConfig(), any()) } coAnswers { + try { + awaitCancellation() + } finally { + cancelled.complete(Unit) + } + } + val registry = registryFor(backgroundScope) { manager } + + registry.connect(server) + runCurrent() + registry.disconnect(server.toServerRef()) + runCurrent() + + assertTrue(cancelled.isCompleted) + assertEquals(ConnectionState.Disconnected, registry.connectionState(server.toServerRef()).value) + coVerify(exactly = 1) { manager.disconnect() } + } + @Test fun `connect saved server uses persisted password when caller omits one`() = runTest { val server = SavedServerRegistry.fromConnection("http://authenticated.example.com", "Authenticated") @@ -179,12 +497,31 @@ class ServerConnectionRegistryTest { private fun successfulManager(server: dev.blazelight.p4oc.core.datastore.SavedServer): ConnectionManager { val manager = mockk(relaxed = true) - every { manager.connection } returns MutableStateFlow(null) + val connection = mockk { + every { config } returns server.toServerConfig() + every { generation } returns ServerGeneration(1) + every { eventSource } returns mockk { every { directoryEvents } returns MutableSharedFlow() } + } + every { manager.connection } returns MutableStateFlow(connection) every { manager.connectionState } returns MutableStateFlow(ConnectionState.Connected) coEvery { manager.connect(server.toServerConfig(), any()) } returns Result.success(emptyList()) return manager } + private fun successfulManagerWithEvents( + server: dev.blazelight.p4oc.core.datastore.SavedServer, + generationValue: Long, + events: MutableSharedFlow, + ): ConnectionManager { + val eventSource = mockk { every { directoryEvents } returns events } + val connection = mockk { + every { config } returns server.toServerConfig() + every { generation } returns ServerGeneration(generationValue) + every { this@mockk.eventSource } returns eventSource + } + return successfulManager(server).also { every { it.connection } returns MutableStateFlow(connection) } + } + private fun failingManager( server: dev.blazelight.p4oc.core.datastore.SavedServer, message: String, @@ -197,4 +534,11 @@ class ServerConnectionRegistryTest { ) return manager } + + private fun scopedEvent(serverRef: ServerRef, generation: Long) = ScopedEvent( + serverRef = serverRef, + generation = ServerGeneration(generation), + workspaceKey = WorkspaceKey.Global, + event = OpenCodeEvent.Disconnected(null), + ) } diff --git a/app/src/test/java/dev/blazelight/p4oc/core/network/ServerUrlTest.kt b/app/src/test/java/dev/blazelight/p4oc/core/network/ServerUrlTest.kt index 2ea54e19..812b1fed 100644 --- a/app/src/test/java/dev/blazelight/p4oc/core/network/ServerUrlTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/core/network/ServerUrlTest.kt @@ -1,12 +1,28 @@ package dev.blazelight.p4oc.core.network +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Request import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test class ServerUrlTest { + @Test + fun `authenticated origin requires exact scheme host and port`() { + val configured = "https://server.example:8443".toHttpUrl() + + assertTrue("https://server.example:8443/session".toHttpUrl().hasSameOrigin(configured)) + val webSocketUrl = Request.Builder().url("wss://server.example:8443/pty/id/connect").build().url + assertTrue(webSocketUrl.hasSameOrigin(configured)) + assertFalse("https://other.example:8443/session".toHttpUrl().hasSameOrigin(configured)) + assertFalse("http://server.example:8443/session".toHttpUrl().hasSameOrigin(configured)) + assertFalse("https://server.example/session".toHttpUrl().hasSameOrigin(configured)) + } + @Test fun `bare host defaults to http without persisted port`() { assertEquals("http://example.com", ServerUrl.normalizeConnectUrl("example.com")) @@ -89,4 +105,33 @@ class ServerUrlTest { ServerUrl.endpointKey("http://example.com:4096/a"), ) } + + @Test + fun `cleartext credentials allow exact loopback and private literals`() { + assertTrue(ServerUrl.allowsCleartextCredentials("http://127.0.0.1:4096")) + assertTrue(ServerUrl.allowsCleartextCredentials("http://localhost:4096")) + assertTrue(ServerUrl.allowsCleartextCredentials("http://10.20.30.40")) + assertTrue(ServerUrl.allowsCleartextCredentials("http://172.16.0.1")) + assertTrue(ServerUrl.allowsCleartextCredentials("http://172.31.255.254")) + assertTrue(ServerUrl.allowsCleartextCredentials("http://192.168.1.2")) + assertTrue(ServerUrl.allowsCleartextCredentials("http://[::1]:4096")) + assertTrue(ServerUrl.allowsCleartextCredentials("http://[fd00::1]")) + assertTrue(ServerUrl.allowsCleartextCredentials("http://[fe80::1%wlan0]")) + } + + @Test + fun `cleartext credentials reject public addresses and hostname lookalikes`() { + assertFalse(ServerUrl.allowsCleartextCredentials("http://8.8.8.8")) + assertFalse(ServerUrl.allowsCleartextCredentials("http://172.15.0.1")) + assertFalse(ServerUrl.allowsCleartextCredentials("http://172.32.0.1")) + assertFalse(ServerUrl.allowsCleartextCredentials("http://localhost.example.com")) + assertFalse(ServerUrl.allowsCleartextCredentials("http://127.0.0.1.example.com")) + assertFalse(ServerUrl.allowsCleartextCredentials("http://192.168.1.2.example.com")) + } + + @Test + fun `https credentials are allowed for public endpoints`() { + assertTrue(ServerUrl.allowsCleartextCredentials("https://example.com")) + assertTrue(ServerUrl.allowsCleartextCredentials("https://8.8.8.8")) + } } diff --git a/app/src/test/java/dev/blazelight/p4oc/core/notification/NotificationEventObserverTest.kt b/app/src/test/java/dev/blazelight/p4oc/core/notification/NotificationEventObserverTest.kt new file mode 100644 index 00000000..e6f0251d --- /dev/null +++ b/app/src/test/java/dev/blazelight/p4oc/core/notification/NotificationEventObserverTest.kt @@ -0,0 +1,98 @@ +package dev.blazelight.p4oc.core.notification + +import dev.blazelight.p4oc.core.datastore.NotificationSettings +import dev.blazelight.p4oc.domain.server.ServerRef +import dev.blazelight.p4oc.domain.server.WorkspaceKey +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class NotificationEventObserverTest { + private val serverA = ServerRef.fromEndpointKey("https://a.example:4096") + private val serverB = ServerRef.fromEndpointKey("https://b.example:4096") + private val workspaceA = WorkspaceKey.Directory("/workspace/a") + private val workspaceB = WorkspaceKey.Directory("/workspace/b") + + @Test + fun `completion feedback including haptic is disabled by completion setting`() { + val disabled = NotificationSettings(enabled = true, notifyOnCompletion = false) + val enabled = disabled.copy(notifyOnCompletion = true) + + assertFalse(shouldEmitCompletionFeedback(disabled, isInForeground = false)) + assertFalse(shouldEmitCompletionFeedback(enabled, isInForeground = true)) + assertTrue(shouldEmitCompletionFeedback(enabled, isInForeground = false)) + } + + @Test + fun `completion consumes busy state exactly once`() { + val tracker = CompletionTracker() + val route = route("session", serverA, workspaceA) + + tracker.markBusy(route) + + assertTrue(tracker.complete(route)) + assertFalse(tracker.complete(route)) + } + + @Test + fun `foreground transition clears all tracked work`() { + val tracker = CompletionTracker() + val first = route("first", serverA, workspaceA) + val second = route("second", serverB, workspaceB) + tracker.markBusy(first) + tracker.markBusy(second) + + tracker.clear() + + assertFalse(tracker.complete(first)) + assertFalse(tracker.complete(second)) + } + + @Test + fun `disconnect clears only disconnected server`() { + val tracker = CompletionTracker() + val disconnected = route("session-a", serverA, workspaceA) + val stillConnected = route("session-b", serverB, workspaceA) + tracker.markBusy(disconnected) + tracker.markBusy(stillConnected) + + tracker.clearServer(serverA) + + assertFalse(tracker.complete(disconnected)) + assertTrue(tracker.complete(stillConnected)) + } + + @Test + fun `global teardown clears every workspace owned by disposed server`() { + val tracker = CompletionTracker() + val firstWorkspace = route("session-a", serverA, workspaceA) + val secondWorkspace = route("session-b", serverA, workspaceB) + val replacementServer = route("session-c", serverB, workspaceA) + tracker.markBusy(firstWorkspace) + tracker.markBusy(secondWorkspace) + tracker.markBusy(replacementServer) + + tracker.clearServer(serverA) + + assertFalse(tracker.complete(firstWorkspace)) + assertFalse(tracker.complete(secondWorkspace)) + assertTrue(tracker.complete(replacementServer)) + } + + @Test + fun `workspace teardown preserves other work on same server`() { + val tracker = CompletionTracker() + val disposed = route("session-a", serverA, workspaceA) + val retained = route("session-b", serverA, workspaceB) + tracker.markBusy(disposed) + tracker.markBusy(retained) + + tracker.clearWorkspace(serverA, workspaceA) + + assertFalse(tracker.complete(disposed)) + assertTrue(tracker.complete(retained)) + } + + private fun route(sessionId: String, serverRef: ServerRef, workspaceKey: WorkspaceKey) = + NotificationRoute(sessionId, serverRef, workspaceKey) +} diff --git a/app/src/test/java/dev/blazelight/p4oc/core/notification/NotificationHelperTest.kt b/app/src/test/java/dev/blazelight/p4oc/core/notification/NotificationHelperTest.kt new file mode 100644 index 00000000..4706de8a --- /dev/null +++ b/app/src/test/java/dev/blazelight/p4oc/core/notification/NotificationHelperTest.kt @@ -0,0 +1,61 @@ +package dev.blazelight.p4oc.core.notification + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Test + +class NotificationHelperTest { + @Test + fun `text below the bound is unchanged`() { + val text = "a".repeat(MAX_NOTIFICATION_TEXT_CODE_POINTS - 1) + + assertSame(text, boundedNotificationText(text, "fallback")) + } + + @Test + fun `text exactly at the bound is unchanged`() { + val text = "a".repeat(MAX_NOTIFICATION_TEXT_CODE_POINTS) + + assertSame(text, boundedNotificationText(text, "fallback")) + } + + @Test + fun `text above the bound replaces the final allowed code point with an ellipsis`() { + val text = "a".repeat(MAX_NOTIFICATION_TEXT_CODE_POINTS + 1) + + assertEquals( + "a".repeat(MAX_NOTIFICATION_TEXT_CODE_POINTS - 1) + "…", + boundedNotificationText(text, "fallback"), + ) + } + + @Test + fun `surrogate pair at truncation boundary remains intact`() { + val prefix = "a".repeat(MAX_NOTIFICATION_TEXT_CODE_POINTS - 1) + val text = prefix + "\uD83D\uDE80" + "tail" + + val bounded = boundedNotificationText(text, "fallback") + + assertEquals(prefix + "…", bounded) + assertEquals(MAX_NOTIFICATION_TEXT_CODE_POINTS, bounded.codePointCount(0, bounded.length)) + assertFalse(bounded.any { Character.isSurrogate(it) }) + } + + @Test + fun `null text uses fallback without changing it`() { + val fallback = "Session completed" + + assertSame(fallback, boundedNotificationText(null, fallback)) + } + + @Test + fun `oversized fallback is bounded too`() { + val fallback = "f".repeat(MAX_NOTIFICATION_TEXT_CODE_POINTS + 1) + + assertEquals( + "f".repeat(MAX_NOTIFICATION_TEXT_CODE_POINTS - 1) + "…", + boundedNotificationText(null, fallback), + ) + } +} diff --git a/app/src/test/java/dev/blazelight/p4oc/core/notification/NotificationRouteCodecTest.kt b/app/src/test/java/dev/blazelight/p4oc/core/notification/NotificationRouteCodecTest.kt new file mode 100644 index 00000000..dd8a097a --- /dev/null +++ b/app/src/test/java/dev/blazelight/p4oc/core/notification/NotificationRouteCodecTest.kt @@ -0,0 +1,128 @@ +package dev.blazelight.p4oc.core.notification + +import android.content.Intent +import dev.blazelight.p4oc.domain.server.ServerRef +import dev.blazelight.p4oc.domain.server.WorkspaceKey +import dev.blazelight.p4oc.domain.session.SessionId +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class NotificationRouteCodecTest { + @Test + fun `identity distinguishes equal session ids across server workspace and kind`() { + val first = NotificationRoute( + sessionId = "shared-session", + serverRef = ServerRef.fromEndpointKey("https://one.example"), + workspaceKey = WorkspaceKey.Directory("/repo"), + ) + val otherServer = first.copy(serverRef = ServerRef.fromEndpointKey("https://two.example")) + val otherWorkspace = first.copy(workspaceKey = WorkspaceKey.Directory("/other")) + + assertNotEquals( + NotificationRouteCodec.identity(NotificationKind.Permission, first), + NotificationRouteCodec.identity(NotificationKind.Permission, otherServer), + ) + assertNotEquals( + NotificationRouteCodec.identity(NotificationKind.Permission, first), + NotificationRouteCodec.identity(NotificationKind.Permission, otherWorkspace), + ) + assertNotEquals( + NotificationRouteCodec.identity(NotificationKind.Permission, first), + NotificationRouteCodec.identity(NotificationKind.Question, first), + ) + } + + @Test + fun `identity is collision resistant when route hash codes collide`() { + // Java strings Aa and BB intentionally have equal hash codes. + val first = NotificationRoute( + sessionId = "Aa", + serverRef = ServerRef.fromEndpointKey("https://server.example"), + workspaceKey = WorkspaceKey.Global, + ) + val second = first.copy(sessionId = "BB") + + assertEquals(first.hashCode(), second.hashCode()) + assertNotEquals( + NotificationRouteCodec.identity(NotificationKind.Completion, first), + NotificationRouteCodec.identity(NotificationKind.Completion, second), + ) + } + + @Test + fun `directory workspace route round trips all ownership fields`() { + val expected = NotificationRoute( + sessionId = "session-1", + serverRef = ServerRef.fromEndpointKey("https://server.example:4096"), + workspaceKey = WorkspaceKey.Directory("/owned/repository"), + ) + assertEquals( + expected, + NotificationRouteCodec.decode("session-1", "https://server.example:4096", "directory", "/owned/repository"), + ) + } + + @Test + fun `global and session scoped workspaces round trip explicitly`() { + listOf( + WorkspaceKey.Global, + WorkspaceKey.SessionScoped(SessionId("scope-session")), + ).forEach { workspace -> + val expected = NotificationRoute( + sessionId = "target-session", + serverRef = ServerRef.fromEndpointKey("http://localhost:4096"), + workspaceKey = workspace, + ) + val type = if (workspace == WorkspaceKey.Global) "global" else "session" + val value = (workspace as? WorkspaceKey.SessionScoped)?.sessionId?.value + assertEquals( + expected, + NotificationRouteCodec.decode("target-session", "http://localhost:4096", type, value), + ) + } + } + + @Test + fun `legacy session-only notification is rejected instead of guessing workspace`() { + assertNull(NotificationRouteCodec.decode("session-1", null, null, null)) + } + + @Test + fun `incomplete or blank ownership is rejected`() { + assertNull(NotificationRouteCodec.decode("session-1", "server", null, null)) + assertNull(NotificationRouteCodec.decode("session-1", " ", "global", null)) + } + + @Test + fun `cleared route cannot be read again after recreation`() { + val extras = mutableMapOf( + "notification.sessionId" to "session-1", + "notification.serverEndpointKey" to "https://server.example:4096", + "notification.workspaceType" to "directory", + "notification.workspaceValue" to "/owned/repository", + ) + val intent = mockk() + every { intent.getStringExtra(any()) } answers { extras[firstArg()] } + every { intent.removeExtra(any()) } answers { + extras.remove(firstArg()) + intent + } + + assertEquals( + NotificationRoute( + sessionId = "session-1", + serverRef = ServerRef.fromEndpointKey("https://server.example:4096"), + workspaceKey = WorkspaceKey.Directory("/owned/repository"), + ), + NotificationRouteCodec.read(intent), + ) + + NotificationRouteCodec.clear(intent) + + assertNull(NotificationRouteCodec.read(intent)) + } +} diff --git a/app/src/test/java/dev/blazelight/p4oc/core/security/CredentialBackupRulesTest.kt b/app/src/test/java/dev/blazelight/p4oc/core/security/CredentialBackupRulesTest.kt new file mode 100644 index 00000000..9d6eb255 --- /dev/null +++ b/app/src/test/java/dev/blazelight/p4oc/core/security/CredentialBackupRulesTest.kt @@ -0,0 +1,29 @@ +package dev.blazelight.p4oc.core.security + +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +class CredentialBackupRulesTest { + @Test + fun `encrypted credential preferences are excluded from backup and transfer`() { + val backupRules = resourceFile("backup_rules.xml").readText() + val extractionRules = resourceFile("data_extraction_rules.xml").readText() + val exclusion = "" + + assertTrue("Legacy backup rules must exclude credentials", exclusion in backupRules) + assertTrue( + "Cloud and device-transfer rules must both exclude credentials", + extractionRules.windowed(exclusion.length).count { it == exclusion } == 2, + ) + } + + private fun resourceFile(name: String): File { + val candidates = listOf( + File("src/main/res/xml/$name"), + File("app/src/main/res/xml/$name"), + ) + return candidates.firstOrNull(File::isFile) + ?: error("Could not locate $name from ${File(".").absolutePath}") + } +} diff --git a/app/src/test/java/dev/blazelight/p4oc/core/security/CredentialStoreRecoveryTest.kt b/app/src/test/java/dev/blazelight/p4oc/core/security/CredentialStoreRecoveryTest.kt new file mode 100644 index 00000000..e8bff85c --- /dev/null +++ b/app/src/test/java/dev/blazelight/p4oc/core/security/CredentialStoreRecoveryTest.kt @@ -0,0 +1,59 @@ +package dev.blazelight.p4oc.core.security + +import android.content.Context +import android.content.SharedPreferences +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test +import java.io.IOException + +class CredentialStoreRecoveryTest { + private val context = mockk() + + @Test + fun `first create failure deletes credential preferences and retries once`() { + var creates = 0 + var deletes = 0 + + CredentialStore( + context = context, + createPreferences = { + creates += 1 + if (creates == 1) throw IOException("corrupt keyset") + mockk() + }, + deletePreferences = { + deletes += 1 + }, + ) + + assertEquals(2, creates) + assertEquals(1, deletes) + } + + @Test + fun `second create failure is surfaced without another reset`() { + var creates = 0 + var deletes = 0 + val repeatedFailure = IOException("still corrupt") + + val thrown = assertThrows(IOException::class.java) { + CredentialStore( + context = context, + createPreferences = { + creates += 1 + if (creates == 1) throw IOException("corrupt keyset") + throw repeatedFailure + }, + deletePreferences = { + deletes += 1 + }, + ) + } + + assertEquals(repeatedFailure, thrown) + assertEquals(2, creates) + assertEquals(1, deletes) + } +} diff --git a/app/src/test/java/dev/blazelight/p4oc/data/files/FilePathValidatorTest.kt b/app/src/test/java/dev/blazelight/p4oc/data/files/FilePathValidatorTest.kt index 37de478a..3e47bded 100644 --- a/app/src/test/java/dev/blazelight/p4oc/data/files/FilePathValidatorTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/data/files/FilePathValidatorTest.kt @@ -50,6 +50,7 @@ class FilePathValidatorTest { @Test fun `mutation accepts safe relative paths`() { assertEquals("src/Main.kt", FilePathValidator.normalizeForMutation("src//./Main.kt").getOrThrow()) + assertEquals("report:v2.txt", FilePathValidator.normalizeForMutation("report:v2.txt").getOrThrow()) } @Test diff --git a/app/src/test/java/dev/blazelight/p4oc/data/files/ofish/OfishCapabilityParserTest.kt b/app/src/test/java/dev/blazelight/p4oc/data/files/ofish/OfishCapabilityParserTest.kt index 940bbc72..9757b3bd 100644 --- a/app/src/test/java/dev/blazelight/p4oc/data/files/ofish/OfishCapabilityParserTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/data/files/ofish/OfishCapabilityParserTest.kt @@ -11,7 +11,7 @@ class OfishCapabilityParserTest { """ unrelated #OFISH_HELLO - caps base64=1 base64_decode=-d hash=sha256sum mv=1 mkdir=1 rm=1 awk=1 mktemp=1 + caps base64=1 base64_decode=-d hash=sha256sum mv=1 mkdir=1 rm=1 awk=1 mktemp=1 chmod=1 mode=stat -c %a ### 200 ok """.trimIndent(), ) @@ -20,25 +20,29 @@ class OfishCapabilityParserTest { val caps = (result as OfishProbeResult.Available).capabilities assertEquals(HashCommand.SHA256SUM, caps.hashCommand) assertEquals("-d", caps.base64DecodeFlag) + assertEquals(ModeCommand.STAT_GNU, caps.modeCommand) assertTrue(caps.supportsMutation) } @Test fun `parse available shasum and BSD base64 decode`() { val result = OfishCapabilityParser.parse( - "caps base64=1 base64_decode=-D hash=shasum -a 256 mv=1 mkdir=1 rm=1 awk=1 mktemp=1\n### 200 ok", + "caps base64=1 base64_decode=-D hash=shasum -a 256 " + + "mv=1 mkdir=1 rm=1 awk=1 mktemp=1 chmod=1 mode=stat -f %Lp\n### 200 ok", ) assertTrue(result is OfishProbeResult.Available) val caps = (result as OfishProbeResult.Available).capabilities assertEquals(HashCommand.SHASUM_256, caps.hashCommand) assertEquals("-D", caps.base64DecodeFlag) + assertEquals(ModeCommand.STAT_BSD, caps.modeCommand) } @Test fun `parse missing capabilities from 501 status`() { val result = OfishCapabilityParser.parse( - "caps base64=0 base64_decode= hash= mv=1 mkdir=1 rm=1 awk=0 mktemp=1\n### 501 caps_missing base64 hash awk", + "caps base64=0 base64_decode= hash= mv=1 mkdir=1 rm=1 awk=0 " + + "mktemp=1 chmod=1 mode=stat -c %a\n### 501 caps_missing base64 hash awk", ) assertTrue(result is OfishProbeResult.Missing) @@ -51,7 +55,8 @@ class OfishCapabilityParserTest { assertTrue(OfishCapabilityParser.parse("caps base64=1") is OfishProbeResult.Failed) assertTrue( OfishCapabilityParser.parse( - "### 200 ok\ncaps base64=1 base64_decode=-d hash=sha256sum mv=1 mkdir=1 rm=1 awk=1 mktemp=1", + "### 200 ok\ncaps base64=1 base64_decode=-d hash=sha256sum " + + "mv=1 mkdir=1 rm=1 awk=1 mktemp=1 chmod=1 mode=stat -c %a", ) is OfishProbeResult.Failed, ) } diff --git a/app/src/test/java/dev/blazelight/p4oc/data/files/ofish/OfishCommandBuilderTest.kt b/app/src/test/java/dev/blazelight/p4oc/data/files/ofish/OfishCommandBuilderTest.kt index d6b88345..4da13c6c 100644 --- a/app/src/test/java/dev/blazelight/p4oc/data/files/ofish/OfishCommandBuilderTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/data/files/ofish/OfishCommandBuilderTest.kt @@ -15,6 +15,8 @@ class OfishCommandBuilderTest { hasRm = true, hasAwk = true, hasMktemp = true, + hasChmod = true, + modeCommand = ModeCommand.STAT_GNU, ) private val builder = OfishCommandBuilder() @@ -59,6 +61,34 @@ class OfishCommandBuilderTest { assertTrue(script.contains("mv -f")) } + @Test + fun `write rejects directory before expected hash check and temp creation`() { + val script = builder.write("dir", "content", "expected", capabilities).decodedScript() + val directoryGuard = script.indexOf("### 412 precondition reason=directory") + + assertTrue(directoryGuard >= 0) + assertTrue(directoryGuard < script.indexOf("if [ -n \"\$EXPECTED\" ]")) + assertTrue(directoryGuard < script.indexOf("mktemp")) + assertTrue(directoryGuard < script.indexOf("mv -f")) + } + + @Test + fun `write guards symlink before capture and immediately before move`() { + val script = builder.write("file.txt", "content", "expected", capabilities).decodedScript() + val guard = "if [ -L \"\$P\" ]" + val firstGuard = script.indexOf(guard) + val lastGuard = script.lastIndexOf(guard) + val hashCapture = script.indexOf("ACTUAL=\$(hash_file") + val modeCapture = script.indexOf("MODE=\$(stat -c") + val move = script.indexOf("mv -f") + + assertTrue(firstGuard >= 0) + assertTrue(firstGuard < hashCapture) + assertTrue(firstGuard < modeCapture) + assertTrue(lastGuard < move) + assertTrue(lastGuard > script.indexOf("chmod \"\$MODE\"")) + } + @Test fun `write uses base64 heredoc not raw payload`() { val raw = "hello secret content" @@ -106,6 +136,7 @@ class OfishCommandBuilderTest { assertTrue(script.contains("TO='dir/new'\\''name.txt'")) assertTrue(script.contains("if [ -e \"\$TO\" ]; then printf '### 409 conflict")) assertTrue(script.contains("mv -- \"\$FROM\" \"\$TO\"")) + assertTrue(script.contains("if [ -L \"\$FROM\" ] || [ -L \"\$TO\" ]")) } @Test @@ -119,6 +150,7 @@ class OfishCommandBuilderTest { assertTrue(chunk.decodedScript().contains("#OFISH_UPLOAD_CHUNK")) assertTrue(chunk.decodedScript().contains("<<'__OFISH_PAYLOAD__'")) assertTrue(chunk.decodedScript().contains("### 412 precondition reason=missing_tmp")) + assertTrue(chunk.decodedScript().contains("### 412 precondition reason=symlink")) assertTrue(finish.decodedScript().contains("#OFISH_UPLOAD_FINISH")) assertTrue(abort.decodedScript().contains("#OFISH_UPLOAD_ABORT")) } diff --git a/app/src/test/java/dev/blazelight/p4oc/data/files/ofish/OfishCommandProcessTest.kt b/app/src/test/java/dev/blazelight/p4oc/data/files/ofish/OfishCommandProcessTest.kt index b79eb037..3dfb3a1e 100644 --- a/app/src/test/java/dev/blazelight/p4oc/data/files/ofish/OfishCommandProcessTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/data/files/ofish/OfishCommandProcessTest.kt @@ -9,6 +9,8 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder import java.io.File +import java.nio.file.Files +import java.nio.file.attribute.PosixFilePermission import java.security.MessageDigest class OfishCommandProcessTest { @@ -24,6 +26,8 @@ class OfishCommandProcessTest { hasRm = true, hasAwk = true, hasMktemp = true, + hasChmod = true, + modeCommand = ModeCommand.STAT_GNU, ) private val builder = OfishCommandBuilder() @@ -34,7 +38,7 @@ class OfishCommandProcessTest { val output = runShell(builder.write("a/b/file.txt", "hello", null, capabilities), root) - assertTrue(OfishMutationParser.parse(output) is OfishMutationStatus.Ok) + assertTrue(OfishMutationParser.parse(output, "#OFISH_WRITE") is OfishMutationStatus.Ok) assertEquals("hello", File(root, "a/b/file.txt").readText()) } @@ -47,7 +51,7 @@ class OfishCommandProcessTest { val output = runShell(builder.write("file.txt", "new", "wrong", capabilities), root) - assertTrue(OfishMutationParser.parse(output) is OfishMutationStatus.Conflict) + assertTrue(OfishMutationParser.parse(output, "#OFISH_WRITE") is OfishMutationStatus.Conflict) assertEquals("old", target.readText()) } @@ -60,10 +64,120 @@ class OfishCommandProcessTest { val output = runShell(builder.write("file.txt", "new", sha256("old".toByteArray()), capabilities), root) - assertTrue(OfishMutationParser.parse(output) is OfishMutationStatus.Ok) + assertTrue(OfishMutationParser.parse(output, "#OFISH_WRITE") is OfishMutationStatus.Ok) assertEquals("new", target.readText()) } + @Test + fun `write preserves executable destination mode`() { + assumeShellAvailable() + val root = temporaryFolder.newFolder() + val target = File(root, "script.sh").apply { + writeText("old") + } + Files.setPosixFilePermissions(target.toPath(), MODE_0755) + + val output = builder.write("script.sh", "new", null, capabilities).runIn(root) + + assertTrue(OfishMutationParser.parse(output, "#OFISH_WRITE") is OfishMutationStatus.Ok) + assertEquals("new", target.readText()) + assertEquals(MODE_0755, Files.getPosixFilePermissions(target.toPath())) + } + + @Test + fun `write new file retains safe non executable default`() { + assumeShellAvailable() + val root = temporaryFolder.newFolder() + val target = File(root, "new.txt") + + val output = builder.write("new.txt", "new", null, capabilities).runIn(root) + + assertTrue(OfishMutationParser.parse(output, "#OFISH_WRITE") is OfishMutationStatus.Ok) + assertEquals(MODE_0600, Files.getPosixFilePermissions(target.toPath())) + } + + @Test + fun `write rejects directory without moving temp into it`() { + assumeShellAvailable() + val root = temporaryFolder.newFolder() + val target = File(root, "target").apply { mkdir() } + val existing = File(target, "existing.txt").apply { writeText("preserved") } + + val output = runShell(builder.write("target", "new", null, capabilities), root) + + assertEquals( + OfishMutationStatus.PreconditionFailed("directory"), + OfishMutationParser.parse(output, "#OFISH_WRITE") + ) + assertTrue(target.isDirectory) + assertEquals("preserved", existing.readText()) + assertEquals(listOf("existing.txt"), target.list()?.toList()) + } + + @Test + fun `write with expected hash rejects directory as directory`() { + assumeShellAvailable() + val root = temporaryFolder.newFolder() + val target = File(root, "target").apply { mkdir() } + + val output = runShell(builder.write("target", "new", sha256(byteArrayOf()), capabilities), root) + + assertEquals( + OfishMutationStatus.PreconditionFailed("directory"), + OfishMutationParser.parse(output, "#OFISH_WRITE") + ) + assertTrue(target.isDirectory) + assertTrue(target.list()?.isEmpty() == true) + } + + @Test + fun `write rejects destination symlink without changing its target`() { + assumeShellAvailable() + val root = temporaryFolder.newFolder() + val external = File(root, "external.txt").apply { writeText("preserved") } + val link = File(root, "file.txt") + Files.createSymbolicLink(link.toPath(), external.toPath()) + + val output = builder.write("file.txt", "new", null, capabilities).runIn(root) + + assertEquals( + OfishMutationStatus.PreconditionFailed("symlink"), + OfishMutationParser.parse(output, "#OFISH_WRITE"), + ) + assertTrue(Files.isSymbolicLink(link.toPath())) + assertEquals("preserved", external.readText()) + } + + @Test + fun `write rechecks destination symlink immediately before replacement`() { + assumeShellAvailable() + val root = temporaryFolder.newFolder() + val target = File(root, "file.txt").apply { writeText("old") } + val external = File(root, "external.txt").apply { writeText("preserved") } + val tools = File(root, "tools").apply { mkdir() } + File(tools, "chmod").apply { + writeText( + "#!/bin/sh\n/usr/bin/chmod \"\$@\" || exit \$?\n" + + "rm -f file.txt && ln -s external.txt file.txt\n", + ) + setExecutable(true) + } + + val output = runShell( + builder.write("file.txt", "new", null, capabilities), + root, + environment = mapOf("PATH" to "${tools.absolutePath}:${System.getenv("PATH")}"), + ) + + assertEquals( + OfishMutationStatus.PreconditionFailed("symlink"), + OfishMutationParser.parse(output, "#OFISH_WRITE"), + ) + assertTrue(Files.isSymbolicLink(target.toPath())) + assertEquals("preserved", external.readText()) + assertTrue(root.listFiles()?.none { it.name.startsWith(".ofish.") } == true) + } + @Test fun `delete removes file and rejects directory`() { assumeShellAvailable() @@ -73,16 +187,66 @@ class OfishCommandProcessTest { val deleteOutput = runShell(builder.delete("file.txt"), root) - assertEquals(OfishMutationStatus.Deleted, OfishMutationParser.parse(deleteOutput)) + assertEquals(OfishMutationStatus.Deleted, OfishMutationParser.parse(deleteOutput, "#OFISH_DELETE")) assertFalse(target.exists()) File(root, "dir").mkdir() val directoryOutput = runShell(builder.delete("dir"), root) - assertEquals(OfishMutationStatus.PreconditionFailed("directory"), OfishMutationParser.parse(directoryOutput)) + assertEquals( + OfishMutationStatus.PreconditionFailed("directory"), + OfishMutationParser.parse(directoryOutput, "#OFISH_DELETE") + ) assertTrue(File(root, "dir").isDirectory) } + @Test + fun `delete and mkdir reject symlinks`() { + assumeShellAvailable() + val root = temporaryFolder.newFolder() + val external = File(root, "external.txt").apply { writeText("preserved") } + val link = File(root, "link") + Files.createSymbolicLink(link.toPath(), external.toPath()) + + val deleteOutput = builder.delete("link").runIn(root) + val mkdirOutput = builder.mkdir("link").runIn(root) + + assertEquals( + OfishMutationStatus.PreconditionFailed("symlink"), + OfishMutationParser.parse(deleteOutput, "#OFISH_DELETE"), + ) + assertEquals( + OfishMutationStatus.PreconditionFailed("symlink"), + OfishMutationParser.parse(mkdirOutput, "#OFISH_MKDIR"), + ) + assertTrue(Files.isSymbolicLink(link.toPath())) + assertEquals("preserved", external.readText()) + } + + @Test + fun `rename rejects symlink source and destination`() { + assumeShellAvailable() + val root = temporaryFolder.newFolder() + val external = File(root, "external.txt").apply { writeText("preserved") } + Files.createSymbolicLink(File(root, "source-link").toPath(), external.toPath()) + + val sourceOutput = builder.rename("source-link", "renamed").runIn(root) + File(root, "source.txt").writeText("source") + Files.createSymbolicLink(File(root, "destination-link").toPath(), external.toPath()) + val destinationOutput = builder.rename("source.txt", "destination-link").runIn(root) + + assertEquals( + OfishMutationStatus.PreconditionFailed("symlink"), + OfishMutationParser.parse(sourceOutput, "#OFISH_RENAME"), + ) + assertEquals( + OfishMutationStatus.PreconditionFailed("symlink"), + OfishMutationParser.parse(destinationOutput, "#OFISH_RENAME"), + ) + assertEquals("source", File(root, "source.txt").readText()) + assertEquals("preserved", external.readText()) + } + @Test fun `capability probe command runs under zsh invoking sh wrapper`() { assumeShellAvailable() @@ -100,32 +264,104 @@ class OfishCommandProcessTest { val root = temporaryFolder.newFolder() val bytes = byteArrayOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9) - val init = OfishMutationParser.parse(builder.uploadInit("out/file.bin", null, capabilities).runIn(root)) + val init = OfishMutationParser.parse( + builder.uploadInit("out/file.bin", null, capabilities).runIn(root), + "#OFISH_UPLOAD_INIT" + ) assertTrue(init is OfishMutationStatus.Ok) val token = (init as OfishMutationStatus.Ok).uploadToken ?: error("missing token") bytes.toList().chunked(4).forEach { chunk -> val status = OfishMutationParser.parse( - builder.uploadChunk(token, chunk.toByteArray(), capabilities).runIn(root) + builder.uploadChunk(token, chunk.toByteArray(), capabilities).runIn(root), + "#OFISH_UPLOAD_CHUNK" ) assertTrue(status is OfishMutationStatus.Ok) } val finish = OfishMutationParser.parse( - builder.uploadFinish("out/file.bin", token, null, capabilities).runIn(root) + builder.uploadFinish("out/file.bin", token, null, capabilities).runIn(root), + "#OFISH_UPLOAD_FINISH" ) assertTrue(finish is OfishMutationStatus.Ok) assertArrayEquals(bytes, File(root, "out/file.bin").readBytes()) } + @Test + fun `chunk upload preserves executable destination mode`() { + assumeShellAvailable() + val root = temporaryFolder.newFolder() + val target = File(root, "script.sh").apply { + writeText("old") + } + Files.setPosixFilePermissions(target.toPath(), MODE_0755) + val init = OfishMutationParser.parse( + builder.uploadInit("script.sh", null, capabilities).runIn(root), + "#OFISH_UPLOAD_INIT", + ) as OfishMutationStatus.Ok + val token = init.uploadToken ?: error("missing token") + + assertTrue( + OfishMutationParser.parse( + builder.uploadChunk(token, "new".toByteArray(), capabilities).runIn(root), + "#OFISH_UPLOAD_CHUNK", + ) is OfishMutationStatus.Ok, + ) + val finish = builder.uploadFinish("script.sh", token, null, capabilities).runIn(root) + + assertTrue(finish, OfishMutationParser.parse(finish, "#OFISH_UPLOAD_FINISH") is OfishMutationStatus.Ok) + assertEquals("new", target.readText()) + assertEquals(MODE_0755, Files.getPosixFilePermissions(target.toPath())) + } + + @Test + fun `upload init and finish reject destination symlink`() { + assumeShellAvailable() + val root = temporaryFolder.newFolder() + val external = File(root, "external.txt").apply { writeText("preserved") } + val link = File(root, "file.txt") + Files.createSymbolicLink(link.toPath(), external.toPath()) + + val initOutput = builder.uploadInit("file.txt", null, capabilities).runIn(root) + + assertEquals( + OfishMutationStatus.PreconditionFailed("symlink"), + OfishMutationParser.parse(initOutput, "#OFISH_UPLOAD_INIT"), + ) + + Files.delete(link.toPath()) + val init = OfishMutationParser.parse( + builder.uploadInit("file.txt", null, capabilities).runIn(root), + "#OFISH_UPLOAD_INIT", + ) as OfishMutationStatus.Ok + val token = init.uploadToken ?: error("missing token") + builder.uploadChunk(token, "new".toByteArray(), capabilities).runIn(root) + Files.createSymbolicLink(link.toPath(), external.toPath()) + + val finishOutput = builder.uploadFinish("file.txt", token, null, capabilities).runIn(root) + + assertEquals( + OfishMutationStatus.PreconditionFailed("symlink"), + OfishMutationParser.parse(finishOutput, "#OFISH_UPLOAD_FINISH"), + ) + assertTrue(File(root, token).exists()) + assertEquals("preserved", external.readText()) + } + private fun String.runIn(root: File): String = runShell(this, root) - private fun runShell(command: String, cwd: File, shell: String = "/bin/sh"): String { - val process = ProcessBuilder(shell, "-c", command) + private fun runShell( + command: String, + cwd: File, + shell: String = "/bin/sh", + environment: Map = emptyMap(), + ): String { + val processBuilder = ProcessBuilder(shell, "-c", command) .directory(cwd) .redirectErrorStream(true) - .start() + processBuilder.environment().putAll(environment) + val process = processBuilder.start() val output = process.inputStream.bufferedReader().readText() assertEquals(output, 0, process.waitFor()) return output @@ -143,4 +379,20 @@ class OfishCommandProcessTest { private fun sha256(bytes: ByteArray): String = MessageDigest.getInstance("SHA-256") .digest(bytes) .joinToString(separator = "") { "%02x".format(it) } + + private companion object { + val MODE_0755 = setOf( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + PosixFilePermission.OWNER_EXECUTE, + PosixFilePermission.GROUP_READ, + PosixFilePermission.GROUP_EXECUTE, + PosixFilePermission.OTHERS_READ, + PosixFilePermission.OTHERS_EXECUTE, + ) + val MODE_0600 = setOf( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + ) + } } diff --git a/app/src/test/java/dev/blazelight/p4oc/data/files/ofish/OfishFileRepositoryTest.kt b/app/src/test/java/dev/blazelight/p4oc/data/files/ofish/OfishFileRepositoryTest.kt index cb14ec6d..f5badd85 100644 --- a/app/src/test/java/dev/blazelight/p4oc/data/files/ofish/OfishFileRepositoryTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/data/files/ofish/OfishFileRepositoryTest.kt @@ -36,6 +36,8 @@ class OfishFileRepositoryTest { hasRm = true, hasAwk = true, hasMktemp = true, + hasChmod = true, + modeCommand = ModeCommand.STAT_GNU, ) @Test @@ -220,7 +222,11 @@ class OfishFileRepositoryTest { val script = encoded?.let { String(java.util.Base64.getDecoder().decode(it), Charsets.UTF_8) }.orEmpty() val responseText = if (script.contains("#OFISH_HASH")) { val path = hashFor.keys.firstOrNull { script.contains(it) } - if (path != null) "### 200 ok hash=${hashFor.getValue(path)}" else "### 200 ok" + if (path != null) { + "#OFISH_HASH\n### 200 ok hash=${hashFor.getValue(path)}" + } else { + "#OFISH_HASH\n### 200 ok" + } } else { "### 200 ok" } diff --git a/app/src/test/java/dev/blazelight/p4oc/data/files/ofish/OfishMutationClientTest.kt b/app/src/test/java/dev/blazelight/p4oc/data/files/ofish/OfishMutationClientTest.kt index f8926367..921ad534 100644 --- a/app/src/test/java/dev/blazelight/p4oc/data/files/ofish/OfishMutationClientTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/data/files/ofish/OfishMutationClientTest.kt @@ -13,11 +13,14 @@ import dev.blazelight.p4oc.data.remote.dto.ShellCommandRequest import dev.blazelight.p4oc.data.remote.dto.TimeDto import dev.blazelight.p4oc.domain.server.ServerRef import dev.blazelight.p4oc.domain.workspace.Workspace +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.delay import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test import java.io.ByteArrayInputStream @@ -34,6 +37,8 @@ class OfishMutationClientTest { hasRm = true, hasAwk = true, hasMktemp = true, + hasChmod = true, + modeCommand = ModeCommand.STAT_GNU, ) @Test @@ -104,6 +109,65 @@ class OfishMutationClientTest { assertEquals(null, (result as FileOperationResult.Conflict).currentHash) } + @Test + fun `large write uses bounded upload chunks instead of direct write command`() = runTest { + val content = "large editor content\n".repeat(4_000) + val client = FakeOfishWorkspaceClient( + outputs = ArrayDeque( + buildList { + add("### 200 ok upload=.ofish.upload.tmp") + repeat(6) { add("### 200 ok") } + add("### 200 ok hash=written") + }, + ), + ) + val mutationClient = mutationClient( + client, + OfishProbeResult.Available(capabilities), + uploadChunkBytes = 16 * 1024, + ) + + val result = mutationClient.writeFile(FileWriteRequest("file.txt", content, expectedHash = "old")) + + assertTrue(result is FileOperationResult.Ok) + assertEquals("written", (result as FileOperationResult.Ok).data.hash) + val scripts = client.commands.map { it.decodedScript() } + assertEquals(0, scripts.count { it.contains("#OFISH_WRITE") }) + assertEquals(1, scripts.count { it.contains("#OFISH_UPLOAD_INIT") }) + assertEquals(6, scripts.count { it.contains("#OFISH_UPLOAD_CHUNK") }) + assertEquals(1, scripts.count { it.contains("#OFISH_UPLOAD_FINISH") }) + assertTrue(scripts.first().contains("EXPECTED='old'")) + assertTrue(scripts.last().contains("EXPECTED='old'")) + assertEquals(listOf("session-1"), client.deletedIds) + } + + @Test + fun `large write finish conflict aborts temporary file`() = runTest { + val content = "x".repeat(33 * 1024) + val client = FakeOfishWorkspaceClient( + outputs = ArrayDeque( + listOf( + "### 200 ok upload=.ofish.upload.tmp", + "### 200 ok", + "### 409 conflict actual=newer", + "### 204 deleted", + ), + ), + ) + val mutationClient = mutationClient( + client, + OfishProbeResult.Available(capabilities), + uploadChunkBytes = 64 * 1024, + ) + + val result = mutationClient.writeFile(FileWriteRequest("file.txt", content, expectedHash = "old")) + + assertTrue(result is FileOperationResult.Conflict) + assertEquals("newer", (result as FileOperationResult.Conflict).currentHash) + assertTrue(client.commands.last().decodedScript().contains("#OFISH_UPLOAD_ABORT")) + assertEquals(listOf("session-1"), client.deletedIds) + } + @Test fun `upload uses one session for init chunks finish`() = runTest { val client = FakeOfishWorkspaceClient( @@ -154,16 +218,265 @@ class OfishMutationClientTest { assertEquals(2, client.commands.count { it.decodedScript().contains("#OFISH_UPLOAD_CHUNK") }) } + @Test + fun `upload makes progress when bulk stream reads return zero`() = runTest { + val client = FakeOfishWorkspaceClient( + outputs = ArrayDeque( + listOf( + "### 200 ok upload=.ofish.upload.tmp", + "### 200 ok", + "### 200 ok", + "### 200 ok hash=abc", + ), + ), + ) + val mutationClient = mutationClient(client, OfishProbeResult.Available(capabilities), uploadChunkBytes = 2) + val bytes = byteArrayOf(1, 2, 3) + val stream = object : InputStream() { + private var offset = 0 + + override fun read(): Int = if (offset < bytes.size) bytes[offset++].toInt() and 0xff else -1 + + override fun read(buffer: ByteArray, offset: Int, length: Int): Int = 0 + } + val request = FileUploadRequest( + path = "file.bin", + contentLength = bytes.size.toLong(), + openStream = { stream }, + ) + + val result = mutationClient.uploadFile(request) + + assertTrue(result is FileOperationResult.Ok) + assertEquals(2, client.commands.count { it.decodedScript().contains("#OFISH_UPLOAD_CHUNK") }) + assertEquals(listOf("session-1"), client.deletedIds) + } + + @Test + fun `upload short EOF with declared length aborts without finishing`() = runTest { + val client = FakeOfishWorkspaceClient( + outputs = ArrayDeque( + listOf( + "### 200 ok upload=.ofish.upload.tmp", + "### 200 ok", + "### 204 deleted", + ), + ), + ) + val mutationClient = mutationClient(client, OfishProbeResult.Available(capabilities), uploadChunkBytes = 4) + val request = FileUploadRequest( + path = "file.bin", + contentLength = 4, + openStream = { ByteArrayInputStream(byteArrayOf(1, 2)) }, + ) + + val result = mutationClient.uploadFile(request) + + assertTrue(result is FileOperationResult.Failed) + assertTrue((result as FileOperationResult.Failed).message.contains("expected 4 bytes, streamed 2 bytes")) + assertEquals(3, client.commands.size) + assertEquals(1, client.commands.count { it.decodedScript().contains("#OFISH_UPLOAD_CHUNK") }) + assertEquals(0, client.commands.count { it.decodedScript().contains("#OFISH_UPLOAD_FINISH") }) + assertTrue(client.commands.last().decodedScript().contains("#OFISH_UPLOAD_ABORT")) + } + + @Test + fun `upload with unknown content length finishes`() = runTest { + val client = FakeOfishWorkspaceClient( + outputs = ArrayDeque( + listOf( + "### 200 ok upload=.ofish.upload.tmp", + "### 200 ok", + "### 200 ok hash=abc", + ), + ), + ) + val mutationClient = mutationClient(client, OfishProbeResult.Available(capabilities), uploadChunkBytes = 4) + val request = FileUploadRequest( + path = "file.bin", + contentLength = -1, + openStream = { ByteArrayInputStream(byteArrayOf(1, 2)) }, + ) + + val result = mutationClient.uploadFile(request) + + assertTrue(result is FileOperationResult.Ok) + assertEquals("abc", (result as FileOperationResult.Ok).data.hash) + assertEquals(1, client.commands.count { it.decodedScript().contains("#OFISH_UPLOAD_FINISH") }) + assertEquals(0, client.commands.count { it.decodedScript().contains("#OFISH_UPLOAD_ABORT") }) + } + + @Test + fun `upload at source byte ceiling finishes`() = runTest { + val client = FakeOfishWorkspaceClient( + outputs = ArrayDeque( + listOf( + "### 200 ok upload=.ofish.upload.tmp", + "### 200 ok", + "### 200 ok hash=abc", + ), + ), + ) + val mutationClient = mutationClient( + client = client, + probeResult = OfishProbeResult.Available(capabilities), + uploadChunkBytesProvider = FixedUploadChunkBytesProvider(4), + maxUploadSourceBytes = 4, + ) + + val result = mutationClient.uploadFile(uploadRequest("file.bin", byteArrayOf(1, 2, 3, 4))) + + assertTrue(result is FileOperationResult.Ok) + assertEquals(1, client.commands.count { it.decodedScript().contains("#OFISH_UPLOAD_FINISH") }) + assertEquals(0, client.commands.count { it.decodedScript().contains("#OFISH_UPLOAD_ABORT") }) + } + + @Test + fun `known upload above source byte ceiling rejects before mutation or opening stream`() = runTest { + val client = FakeOfishWorkspaceClient() + val mutationClient = mutationClient( + client = client, + probeResult = OfishProbeResult.Available(capabilities), + uploadChunkBytesProvider = FixedUploadChunkBytesProvider(4), + maxUploadSourceBytes = 4, + ) + var opened = false + val request = FileUploadRequest( + path = "file.bin", + contentLength = 5, + openStream = { + opened = true + ByteArrayInputStream(byteArrayOf(1, 2, 3, 4, 5)) + }, + ) + + val result = mutationClient.uploadFile(request) + + assertTrue(result is FileOperationResult.Failed) + assertEquals(UPLOAD_TOO_LARGE_MESSAGE, (result as FileOperationResult.Failed).message) + assertFalse(opened) + assertEquals(0, client.createdTitles.size) + assertEquals(0, client.commands.size) + } + + @Test + fun `unknown upload above source byte ceiling aborts without sending excess chunk`() = runTest { + val client = FakeOfishWorkspaceClient( + outputs = ArrayDeque( + listOf( + "### 200 ok upload=.ofish.upload.tmp", + "### 200 ok", + "### 204 deleted", + ), + ), + ) + val mutationClient = mutationClient( + client = client, + probeResult = OfishProbeResult.Available(capabilities), + uploadChunkBytesProvider = FixedUploadChunkBytesProvider(4), + maxUploadSourceBytes = 4, + ) + val request = FileUploadRequest( + path = "file.bin", + contentLength = -1, + openStream = { ByteArrayInputStream(byteArrayOf(1, 2, 3, 4, 5)) }, + ) + + val result = mutationClient.uploadFile(request) + + assertTrue(result is FileOperationResult.Failed) + assertEquals(UPLOAD_TOO_LARGE_MESSAGE, (result as FileOperationResult.Failed).message) + assertEquals(1, client.commands.count { it.decodedScript().contains("#OFISH_UPLOAD_CHUNK") }) + assertEquals(0, client.commands.count { it.decodedScript().contains("#OFISH_UPLOAD_FINISH") }) + assertTrue(client.commands.last().decodedScript().contains("#OFISH_UPLOAD_ABORT")) + } + + @Test + fun `upload callback failure aborts temporary file and deletes session`() = runTest { + val client = FakeOfishWorkspaceClient( + outputs = ArrayDeque( + listOf( + "### 200 ok upload=.ofish.upload.tmp", + "### 200 ok", + "### 204 deleted", + ), + ), + ) + val mutationClient = mutationClient(client, OfishProbeResult.Available(capabilities), uploadChunkBytes = 2) + + val result = mutationClient.uploadFile( + uploadRequest("file.bin", byteArrayOf(1, 2)) { error("progress failed") }, + ) + + assertTrue(result is FileOperationResult.Failed) + assertEquals(3, client.commands.size) + assertTrue(client.commands.last().decodedScript().contains("#OFISH_UPLOAD_ABORT")) + assertEquals(listOf("session-1"), client.deletedIds) + } + + @Test + fun `upload supports whitespace in destination parent and temp token`() = runTest { + val token = "parent with spaces/.ofish.upload.tmp" + val client = FakeOfishWorkspaceClient( + outputs = ArrayDeque( + listOf( + "### 200 ok upload=$token", + "### 200 ok", + "### 200 ok hash=abc", + ), + ), + ) + val mutationClient = mutationClient(client, OfishProbeResult.Available(capabilities), uploadChunkBytes = 2) + + val result = mutationClient.uploadFile(uploadRequest("parent with spaces/file.bin", byteArrayOf(1, 2))) + + assertTrue(result is FileOperationResult.Ok) + assertTrue(client.commands[1].decodedScript().contains("TMP='$token'")) + assertTrue(client.commands[2].decodedScript().contains("TMP='$token'")) + } + @Test fun `unsafe upload token rejected before chunks`() = runTest { - val client = FakeOfishWorkspaceClient(outputs = ArrayDeque(listOf("### 200 ok upload=/tmp/evil"))) + val client = FakeOfishWorkspaceClient( + outputs = ArrayDeque(listOf("### 200 ok upload=/tmp/evil", "### 204 deleted")), + ) val mutationClient = mutationClient(client, OfishProbeResult.Available(capabilities), uploadChunkBytes = 2) val result = mutationClient.uploadFile(uploadRequest("dir/file.bin", byteArrayOf(1, 2, 3, 4))) assertTrue(result is FileOperationResult.Failed) - assertEquals(1, client.commands.size) - assertTrue(client.commands.single().contains("(base64 -d 2>/dev/null || base64 -D) | sh")) + assertEquals(2, client.commands.size) + assertTrue(client.commands.last().decodedScript().contains("#OFISH_UPLOAD_ABORT")) + assertTrue(client.commands.last().decodedScript().contains("TMP='/tmp/evil'")) + } + + @Test + fun `cancellation after upload init aborts temporary file`() = runTest { + val initReturned = CompletableDeferred() + val continueAfterInit = CompletableDeferred() + val client = FakeOfishWorkspaceClient( + outputs = ArrayDeque(listOf("### 200 ok upload=.ofish.upload.tmp", "### 204 deleted")), + ) + val mutationClient = mutationClient( + client, + OfishProbeResult.Available(capabilities), + uploadChunkBytesProvider = UploadChunkBytesProvider { + initReturned.complete(Unit) + continueAfterInit.await() + 2 + }, + ) + val upload = async { + mutationClient.uploadFile(uploadRequest("file.bin", byteArrayOf(1, 2))) + } + initReturned.await() + + upload.cancel(CancellationException("cancel upload")) + continueAfterInit.complete(Unit) + runCatching { upload.await() } + + assertTrue(client.commands.last().decodedScript().contains("#OFISH_UPLOAD_ABORT")) + assertEquals(listOf("session-1"), client.deletedIds) } @Test @@ -211,7 +524,10 @@ class OfishMutationClientTest { fun `concurrent cached capabilities probe once`() = runTest { val client = FakeOfishWorkspaceClient( outputs = ArrayDeque( - listOf("caps base64=1 base64_decode=-d hash=sha256sum mv=1 mkdir=1 rm=1 awk=1 mktemp=1\n### 200 ok") + listOf( + "caps base64=1 base64_decode=-d hash=sha256sum " + + "mv=1 mkdir=1 rm=1 awk=1 mktemp=1 chmod=1 mode=stat -c %a\n### 200 ok", + ), ), ) val probe = OfishCapabilityProbe(client, OfishSessionFactory(client)) @@ -225,14 +541,16 @@ class OfishMutationClientTest { } private suspend fun assertUploadTokenRejected(uploadToken: String, destinationPath: String) { - val client = FakeOfishWorkspaceClient(outputs = ArrayDeque(listOf("### 200 ok upload=$uploadToken"))) + val client = FakeOfishWorkspaceClient( + outputs = ArrayDeque(listOf("### 200 ok upload=$uploadToken", "### 204 deleted")), + ) val mutationClient = mutationClient(client, OfishProbeResult.Available(capabilities), uploadChunkBytes = 2) val result = mutationClient.uploadFile(uploadRequest(destinationPath, byteArrayOf(1, 2, 3, 4))) assertTrue(result is FileOperationResult.Failed) - assertEquals(1, client.commands.size) - assertTrue(client.commands.single().contains("(base64 -d 2>/dev/null || base64 -D) | sh")) + assertEquals(2, client.commands.size) + assertTrue(client.commands.last().decodedScript().contains("#OFISH_UPLOAD_ABORT")) } private fun mutationClient( @@ -249,6 +567,7 @@ class OfishMutationClientTest { client: FakeOfishWorkspaceClient, probeResult: OfishProbeResult, uploadChunkBytesProvider: UploadChunkBytesProvider, + maxUploadSourceBytes: Long = MAX_UPLOAD_SOURCE_BYTES, ): OfishMutationClient { val probe = OfishCapabilityProbe(client, OfishSessionFactory(client)) return OfishMutationClient( @@ -257,6 +576,7 @@ class OfishMutationClientTest { capabilityCache = FakeCapabilityCache(probe, probeResult), commandBuilder = OfishCommandBuilder(), uploadChunkBytes = uploadChunkBytesProvider, + maxUploadSourceBytes = maxUploadSourceBytes, ) } @@ -286,7 +606,7 @@ class OfishMutationClientTest { override suspend fun get(): OfishProbeResult = result } - private class FakeOfishWorkspaceClient( + private inner class FakeOfishWorkspaceClient( val outputs: ArrayDeque = ArrayDeque(), ) : OfishWorkspaceClient { override val workspace: Workspace = Workspace( @@ -317,7 +637,11 @@ class OfishMutationClientTest { override suspend fun executeShellCommand(sessionId: String, request: ShellCommandRequest): MessageWrapperDto { commands += request.command - return message(outputs.removeFirstOrNull() ?: "### 200 ok") + val script = request.command.decodedScript() + val marker = Regex("#OFISH_[A-Z_]+").find(script)?.value + ?: error("OFISH command did not contain a marker") + val output = outputs.removeFirstOrNull() ?: "### 200 ok" + return message("$marker\n$output") } override suspend fun listSessionsCurrentWorkspace(limit: Int?): List = emptyList() diff --git a/app/src/test/java/dev/blazelight/p4oc/data/files/ofish/OfishMutationParserTest.kt b/app/src/test/java/dev/blazelight/p4oc/data/files/ofish/OfishMutationParserTest.kt index 198a0309..a58dcb38 100644 --- a/app/src/test/java/dev/blazelight/p4oc/data/files/ofish/OfishMutationParserTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/data/files/ofish/OfishMutationParserTest.kt @@ -1,6 +1,12 @@ package dev.blazelight.p4oc.data.files.ofish +import dev.blazelight.p4oc.data.remote.dto.MessageInfoDto +import dev.blazelight.p4oc.data.remote.dto.MessageTimeDto +import dev.blazelight.p4oc.data.remote.dto.MessageWrapperDto +import dev.blazelight.p4oc.data.remote.dto.PartDto +import dev.blazelight.p4oc.data.remote.dto.ToolStateDto import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -9,40 +15,43 @@ class OfishMutationParserTest { fun `parses ok statuses`() { assertEquals( OfishMutationStatus.Ok(code = 200, status = "ok", hash = "abc", values = mapOf("hash" to "abc")), - OfishMutationParser.parse("### 200 ok hash=abc"), + OfishMutationParser.parse("#OFISH_WRITE\n### 200 ok hash=abc", "#OFISH_WRITE"), ) assertEquals( OfishMutationStatus.Ok(code = 201, status = "created", hash = "def", values = mapOf("hash" to "def")), - OfishMutationParser.parse("### 201 created hash=def"), + OfishMutationParser.parse("#OFISH_WRITE\n### 201 created hash=def", "#OFISH_WRITE"), ) } @Test fun `parses delete missing conflict precondition failed and caps missing`() { - assertEquals(OfishMutationStatus.Deleted, OfishMutationParser.parse("### 204 deleted")) - assertEquals(OfishMutationStatus.Missing, OfishMutationParser.parse("### 404 missing")) - assertEquals(OfishMutationStatus.Conflict("abc"), OfishMutationParser.parse("### 409 conflict actual=abc")) + assertEquals(OfishMutationStatus.Deleted, parse("### 204 deleted")) + assertEquals(OfishMutationStatus.Missing, parse("### 404 missing")) + assertEquals(OfishMutationStatus.Conflict("abc"), parse("### 409 conflict actual=abc")) assertEquals( OfishMutationStatus.PreconditionFailed("directory"), - OfishMutationParser.parse("### 412 precondition reason=directory") + parse("### 412 precondition reason=directory") ) assertEquals( OfishMutationStatus.Failed("OFISH mutation failed", "decode"), - OfishMutationParser.parse("### 500 failed reason=decode") + parse("### 500 failed reason=decode") ) assertEquals( OfishMutationStatus.CapabilitiesMissing(listOf("base64", "hash")), - OfishMutationParser.parse("### 501 caps_missing base64 hash") + parse("### 501 caps_missing base64 hash") ) } @Test - fun `uses last status line in noisy output`() { + fun `uses status in expected marker segment despite noisy output`() { val output = """ model text ### 500 failed reason=old + #OFISH_UPLOAD_INIT more text ### 200 ok upload=tmp/file + arbitrary assistant prose + ### 500 failed reason=spoofed """.trimIndent() assertEquals( @@ -52,24 +61,107 @@ class OfishMutationParserTest { uploadToken = "tmp/file", values = mapOf("upload" to "tmp/file") ), - OfishMutationParser.parse(output), + OfishMutationParser.parse(output, "#OFISH_UPLOAD_INIT"), ) } @Test - fun `key value parser keeps values whitespace bounded`() { - val result = OfishMutationParser.parse("### 200 ok upload=tmp extra") + fun `ignores fake statuses outside expected marker segment`() { + assertTrue( + OfishMutationParser.parse( + "### 200 ok\nassistant prose\n#OFISH_DELETE\nno command status\n### 204 deleted", + "#OFISH_WRITE", + ) is OfishMutationStatus.Malformed, + ) + assertTrue( + OfishMutationParser.parse( + "#OFISH_WRITE\nassistant prose\n#OFISH_DELETE\n### 200 ok", + "#OFISH_WRITE", + ) is OfishMutationStatus.Malformed, + ) + assertEquals( + OfishMutationStatus.Failed("OFISH mutation failed", "decode"), + OfishMutationParser.parse( + "#OFISH_WRITE\n### 500 failed reason=decode\nassistant prose\n#OFISH_WRITE\n### 200 ok", + "#OFISH_WRITE", + ), + ) + } + + @Test + fun `extractor binds marker to one structured output segment`() { + val message = message( + PartDto("fake", "session", "message", "text", text = "#OFISH_WRITE\n### 500 failed"), + PartDto( + "tool", + "session", + "message", + "tool", + state = ToolStateDto(status = "completed", output = "#OFISH_WRITE\nnoise\n### 200 ok"), + ), + PartDto("after", "session", "message", "text", text = "### 500 failed reason=spoofed"), + ) assertEquals( - OfishMutationStatus.Ok(code = 200, status = "ok", uploadToken = "tmp", values = mapOf("upload" to "tmp")), + "#OFISH_WRITE\nnoise\n### 200 ok", + OfishShellOutputExtractor.extractMutationSegment(message, "#OFISH_WRITE"), + ) + assertNull(OfishShellOutputExtractor.extractMutationSegment(message, "#OFISH_DELETE")) + } + + @Test + fun `upload parser treats remaining text as token`() { + val result = parse("### 200 ok upload=tmp extra") + + assertEquals( + OfishMutationStatus.Ok( + code = 200, + status = "ok", + uploadToken = "tmp extra", + values = mapOf("upload" to "tmp extra"), + ), + result, + ) + } + + @Test + fun `upload token preserves embedded whitespace`() { + val token = "parent with spaces/.ofish.upload.tmp " + + val result = parse("### 200 ok upload=$token") + + assertEquals( + OfishMutationStatus.Ok( + code = 200, + status = "ok", + uploadToken = token, + values = mapOf("upload" to token), + ), result, ) } @Test fun `malformed when status missing or invalid`() { - assertTrue(OfishMutationParser.parse("no status") is OfishMutationStatus.Malformed) - assertTrue(OfishMutationParser.parse("### nope") is OfishMutationStatus.Malformed) - assertTrue(OfishMutationParser.parse("### 599 odd") is OfishMutationStatus.Malformed) + assertTrue(OfishMutationParser.parse("no status", MARKER) is OfishMutationStatus.Malformed) + assertTrue(parse("### nope") is OfishMutationStatus.Malformed) + assertTrue(parse("### 599 odd") is OfishMutationStatus.Malformed) + } + + private fun parse(status: String): OfishMutationStatus = + OfishMutationParser.parse("$MARKER\n$status", MARKER) + + private fun message(vararg parts: PartDto) = MessageWrapperDto( + info = MessageInfoDto( + id = "message", + sessionID = "session", + time = MessageTimeDto(created = 0), + role = "assistant", + ), + parts = parts.toList(), + ) + + private companion object { + const val MARKER = "#OFISH_WRITE" } } diff --git a/app/src/test/java/dev/blazelight/p4oc/data/remote/dto/PtyDtoContractTest.kt b/app/src/test/java/dev/blazelight/p4oc/data/remote/dto/PtyDtoContractTest.kt new file mode 100644 index 00000000..e8f1e505 --- /dev/null +++ b/app/src/test/java/dev/blazelight/p4oc/data/remote/dto/PtyDtoContractTest.kt @@ -0,0 +1,30 @@ +package dev.blazelight.p4oc.data.remote.dto + +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class PtyDtoContractTest { + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun `PTY decodes running and exited upstream shapes plus nullable legacy pid`() { + val running = json.decodeFromString( + """ + {"id":"p1","title":"shell","command":"bash","args":[],"cwd":"/repo","status":"running","pid":42} + """.trimIndent(), + ) + val exited = json.decodeFromString( + """ + {"id":"p2","title":"done","command":"bash","args":[],"cwd":"/repo","status":"exited", + "pid":null,"exitCode":7} + """.trimIndent(), + ) + + assertEquals(42, running.pid) + assertNull(running.exitCode) + assertNull(exited.pid) + assertEquals(7, exited.exitCode) + } +} diff --git a/app/src/test/java/dev/blazelight/p4oc/data/remote/dto/UpstreamContractDtoTest.kt b/app/src/test/java/dev/blazelight/p4oc/data/remote/dto/UpstreamContractDtoTest.kt new file mode 100644 index 00000000..fc389457 --- /dev/null +++ b/app/src/test/java/dev/blazelight/p4oc/data/remote/dto/UpstreamContractDtoTest.kt @@ -0,0 +1,122 @@ +package dev.blazelight.p4oc.data.remote.dto + +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class UpstreamContractDtoTest { + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun `mcp add response decodes status map`() { + val payload = + """ + { + "local-tools": { "status": "connected" }, + "remote-tools": { "status": "failed", "error": "connection refused" }, + "oauth-tools": { + "status": "needs_client_registration", + "error": "dynamic client registration is unavailable" + } + } + """.trimIndent() + + val statuses = json.decodeFromString>(payload) + + assertEquals("connected", statuses.getValue("local-tools").status) + assertNull(statuses.getValue("local-tools").error) + assertEquals("connection refused", statuses.getValue("remote-tools").error) + assertEquals("needs_client_registration", statuses.getValue("oauth-tools").status) + } + + @Test + fun `mcp add request decodes upstream local config shape`() { + val payload = + """ + { + "name": "local-tools", + "config": { + "type": "local", + "command": ["npx", "tool-server"], + "cwd": "/workspace", + "environment": { "LOG_LEVEL": "info" }, + "enabled": true, + "timeout": 5000 + } + } + """.trimIndent() + + val request = json.decodeFromString(payload) + + assertEquals("local-tools", request.name) + assertEquals("local", request.config.type) + assertEquals(listOf("npx", "tool-server"), request.config.command) + assertEquals("/workspace", request.config.cwd) + assertEquals(mapOf("LOG_LEVEL" to "info"), request.config.environment) + } + + @Test + fun `session diff decodes current upstream snapshot shape`() { + val payload = + """ + [ + { + "file": "src/Main.kt", + "patch": "@@ -1 +1 @@\n-old\n+new", + "additions": 1, + "deletions": 1, + "status": "modified" + }, + { + "additions": 0.0, + "deletions": 0.0 + } + ] + """.trimIndent() + + val diffs = json.decodeFromString>(payload) + + assertEquals("src/Main.kt", diffs.first().file) + assertEquals("@@ -1 +1 @@\n-old\n+new", diffs.first().patch) + assertEquals(1.0, diffs.first().additions, 0.0) + assertEquals("modified", diffs.first().status) + assertNull(diffs.last().file) + assertNull(diffs.last().patch) + } + + @Test + fun `current upstream session preserves workspace model and accounting`() { + val session = json.decodeFromString( + """ + { + "id":"ses_1","slug":"brisk-fox","projectID":"project-1","workspaceID":"wrk_1", + "directory":"/repo","path":"/repo","title":"Work","version":"1.18.3", + "cost":1.5,"tokens":{"input":4,"output":3,"reasoning":2,"cache":{"read":1,"write":0}}, + "agent":"build","model":{"id":"model-1","providerID":"provider-1","variant":"high"}, + "metadata":{"source":"android"},"permission":[],"time":{"created":1,"updated":2} + } + """.trimIndent() + ) + + assertEquals("brisk-fox", session.slug) + assertEquals("wrk_1", session.workspaceID) + assertEquals("model-1", session.model?.id) + assertEquals(4, session.tokens?.input) + } + + @Test + fun `current upstream project and command fields are retained`() { + val project = json.decodeFromString( + """{"id":"p1","worktree":"/repo","name":"P4OC","sandboxes":["/tmp/s1"],"time":{"created":1,"updated":2}}""" + ) + val command = json.decodeFromString( + """{"name":"review","template":"Review this","hints":["file"],"source":"command"}""" + ) + + assertEquals(listOf("/tmp/s1"), project.sandboxes) + assertEquals(2L, project.time.updated) + assertEquals(listOf("file"), command.hints) + assertEquals("command", command.source) + } +} diff --git a/app/src/test/java/dev/blazelight/p4oc/data/remote/mapper/EventMapperTest.kt b/app/src/test/java/dev/blazelight/p4oc/data/remote/mapper/EventMapperTest.kt index bf2618ac..d7ec7b4c 100644 --- a/app/src/test/java/dev/blazelight/p4oc/data/remote/mapper/EventMapperTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/data/remote/mapper/EventMapperTest.kt @@ -1,3 +1,5 @@ +@file:Suppress("ImportOrdering", "Wrapping") + package dev.blazelight.p4oc.data.remote.mapper import dev.blazelight.p4oc.core.log.AppLog @@ -11,6 +13,7 @@ import io.mockk.every import io.mockk.mockkObject import io.mockk.unmockkObject import kotlinx.serialization.json.Json +import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put @@ -39,6 +42,108 @@ class EventMapperTest { unmockkObject(AppLog) } + @Test + fun `maps installation update available version`() { + val dto = EventDataDto( + type = "installation.update-available", + properties = buildJsonObject { + put("version", "1.18.3") + put("ignoredFutureField", true) + }, + ) + + assertEquals( + OpenCodeEvent.InstallationUpdateAvailable("1.18.3"), + eventMapper.mapToEvent(dto), + ) + } + + @Test + fun `maps project updated with current project payload`() { + val dto = EventDataDto( + type = "project.updated", + properties = buildJsonObject { + putJsonObject("info") { + put("id", "project-1") + put("worktree", "/work/projects/one") + put("vcsDir", "/work/projects/one/.git") + put("vcs", "git") + putJsonObject("time") { + put("created", 1_000L) + put("updated", 2_000L) + put("initialized", 1_500L) + } + putJsonArray("sandboxes") { add(JsonPrimitive("/tmp/sandbox")) } + put("name", "Project One") + putJsonObject("icon") { put("color", "blue") } + putJsonObject("commands") { put("test", "./gradlew test") } + } + }, + ) + + val event = eventMapper.mapToEvent(dto) + + assertTrue(event is OpenCodeEvent.ProjectUpdated) + val project = (event as OpenCodeEvent.ProjectUpdated).project + assertEquals("project-1", project.id) + assertEquals("/work/projects/one", project.worktree) + assertEquals("/work/projects/one/.git", project.vcsDir) + assertEquals("git", project.vcs) + assertEquals(1_000L, project.createdAt) + assertEquals(1_500L, project.initializedAt) + } + + @Test + fun `maps project directories updated project id`() { + val event = eventMapper.mapToEvent( + EventDataDto( + type = "project.directories.updated", + properties = buildJsonObject { put("projectID", "project-2") }, + ), + ) + + assertEquals(OpenCodeEvent.ProjectDirectoriesUpdated("project-2"), event) + } + + @Test + fun `maps models dev refreshed`() { + val event = eventMapper.mapToEvent( + EventDataDto(type = "models-dev.refreshed", properties = buildJsonObject {}), + ) + + assertEquals(OpenCodeEvent.ModelsRefreshed, event) + } + + @Test + fun `maps catalog updated`() { + val event = eventMapper.mapToEvent( + EventDataDto(type = "catalog.updated", properties = buildJsonObject {}), + ) + + assertEquals(OpenCodeEvent.CatalogUpdated, event) + } + + @Test + fun `maps mcp tools changed server`() { + val event = eventMapper.mapToEvent( + EventDataDto( + type = "mcp.tools.changed", + properties = buildJsonObject { put("server", "workspace-mcp") }, + ), + ) + + assertEquals(OpenCodeEvent.McpToolsChanged("workspace-mcp"), event) + } + + @Test + fun `maps global disposed`() { + val event = eventMapper.mapToEvent( + EventDataDto(type = "global.disposed", properties = buildJsonObject {}), + ) + + assertEquals(OpenCodeEvent.GlobalDisposed, event) + } + // ── message.updated ───────────────────────────────────────────────────── @Test @@ -214,6 +319,54 @@ class EventMapperTest { assertEquals("once", reply.reply) } + @Test + fun `maps question_v2 events`() { + val asked = eventMapper.mapToEvent( + EventDataDto( + type = "question.v2.asked", + properties = buildJsonObject { + put("id", "que_1") + put("sessionID", "sess-1") + putJsonArray("questions") { + add(buildJsonObject { + put("header", "Confirm") + put("question", "Continue?") + putJsonArray("options") { + add(buildJsonObject { + put("label", "Yes") + put("description", "Continue") + }) + } + }) + } + }, + ) + ) + val replied = eventMapper.mapToEvent( + EventDataDto( + type = "question.v2.replied", + properties = buildJsonObject { + put("sessionID", "sess-1") + put("requestID", "que_1") + putJsonArray("answers") { add(buildJsonArray { add(JsonPrimitive("Yes")) }) } + }, + ) + ) + val rejected = eventMapper.mapToEvent( + EventDataDto( + type = "question.v2.rejected", + properties = buildJsonObject { + put("sessionID", "sess-1") + put("requestID", "que_1") + }, + ) + ) + + assertEquals("que_1", (asked as OpenCodeEvent.QuestionAsked).request.id) + assertEquals(listOf(listOf("Yes")), (replied as OpenCodeEvent.QuestionReplied).answers) + assertEquals("que_1", (rejected as OpenCodeEvent.QuestionRejected).requestID) + } + // ── session.status ────────────────────────────────────────────────────── @Test @@ -302,6 +455,25 @@ class EventMapperTest { assertEquals("Aborted", error?.message) } + @Test + fun `maps session_error provider id for authentication recovery`() { + val properties = buildJsonObject { + put("sessionID", "sess-1") + putJsonObject("error") { + put("name", "ProviderAuthError") + putJsonObject("data") { + put("providerID", "anthropic") + put("message", "sensitive backend details") + } + } + } + + val event = eventMapper.mapToEvent(EventDataDto(type = "session.error", properties = properties)) + + assertTrue(event is OpenCodeEvent.SessionError) + assertEquals("anthropic", (event as OpenCodeEvent.SessionError).error?.providerID) + } + // ── Unknown type ──────────────────────────────────────────────────────── @Test diff --git a/app/src/test/java/dev/blazelight/p4oc/data/session/HydrationEventBufferTest.kt b/app/src/test/java/dev/blazelight/p4oc/data/session/HydrationEventBufferTest.kt index 7425965e..c58f944c 100644 --- a/app/src/test/java/dev/blazelight/p4oc/data/session/HydrationEventBufferTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/data/session/HydrationEventBufferTest.kt @@ -8,8 +8,11 @@ import dev.blazelight.p4oc.domain.session.WorkspaceSession import dev.blazelight.p4oc.domain.workspace.Workspace import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue import org.junit.Test import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicReference class HydrationEventBufferTest { @@ -19,6 +22,12 @@ class HydrationEventBufferTest { ) private val reducer = SessionReducer(workspace) + @Test + fun `non-positive capacity is rejected before buffering`() { + assertThrows(IllegalArgumentException::class.java) { HydrationEventBuffer(capacity = 0) } + assertThrows(IllegalArgumentException::class.java) { HydrationEventBuffer(capacity = -1) } + } + @Test fun `events during hydrate are buffered and replayed after hydrated snapshot`() { val buffer = HydrationEventBuffer() @@ -57,7 +66,7 @@ class HydrationEventBufferTest { } @Test - fun `replay uses snapshot while concurrent buffer can continue`() { + fun `event buffered during blocked replay remains queued for next replay`() { val replayStarted = CountDownLatch(1) val finishReplay = CountDownLatch(1) val reducer = object : SessionReducer(workspace) { @@ -79,14 +88,16 @@ class HydrationEventBufferTest { } replayThread.start() - replayStarted.await() + assertTrue("Replay did not start", replayStarted.await(2, TimeUnit.SECONDS)) buffer.buffer(OpenCodeEvent.SessionCreated(session("second"))) finishReplay.countDown() - replayThread.join() + replayThread.join(2_000) + assertFalse("Replay thread did not finish", replayThread.isAlive) replayError.get()?.let { throw AssertionError("Replay failed", it) } - assertEquals(2, buffer.size) - assertEquals(setOf("first", "second"), buffer.replayOver(Snapshot(), this.reducer).sessions.keys) + assertEquals(1, buffer.size) + assertEquals(setOf("second"), buffer.replayOver(Snapshot(), this.reducer).sessions.keys) + assertEquals(0, buffer.size) } private fun workspaceSession(workspace: Workspace, session: Session): WorkspaceSession = WorkspaceSession( diff --git a/app/src/test/java/dev/blazelight/p4oc/data/session/QuestionReconciliationTest.kt b/app/src/test/java/dev/blazelight/p4oc/data/session/QuestionReconciliationTest.kt index 46f96bad..c17693da 100644 --- a/app/src/test/java/dev/blazelight/p4oc/data/session/QuestionReconciliationTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/data/session/QuestionReconciliationTest.kt @@ -303,6 +303,7 @@ class QuestionReconciliationTest { } @Test + @Suppress("LongMethod") fun `clearQuestion with requestId records dedup id`() = runTest { val sessionId = "sess_001" val questionId = "que_001" @@ -347,10 +348,21 @@ class QuestionReconciliationTest { ) ) ) + repository.acceptEvent( + OpenCodeEvent.QuestionAsked( + QuestionRequest( + id = questionId, + sessionID = sessionId, + questions = emptyList(), + tool = null, + ) + ) + ) advanceUntilIdle() var sessionState = repository.sessionUiState(SessionId(sessionId)) assertNotNull("pendingQuestion should be set", sessionState.value.pendingQuestion) + assertEquals(emptyList(), sessionState.value.queuedQuestions) // Clear with requestId (dismissQuestion path) repository.clearQuestion(SessionId(sessionId), questionId) diff --git a/app/src/test/java/dev/blazelight/p4oc/data/session/SessionOwnershipHydrationTest.kt b/app/src/test/java/dev/blazelight/p4oc/data/session/SessionOwnershipHydrationTest.kt new file mode 100644 index 00000000..c94d503b --- /dev/null +++ b/app/src/test/java/dev/blazelight/p4oc/data/session/SessionOwnershipHydrationTest.kt @@ -0,0 +1,105 @@ +package dev.blazelight.p4oc.data.session + +import dev.blazelight.p4oc.domain.model.OpenCodeEvent +import dev.blazelight.p4oc.domain.model.Permission +import dev.blazelight.p4oc.domain.model.QuestionRequest +import dev.blazelight.p4oc.domain.model.Session +import dev.blazelight.p4oc.domain.session.SessionId +import dev.blazelight.p4oc.fakes.FakeWorkspaceClient +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class SessionOwnershipHydrationTest { + @Test + fun `hydrated child permission and question events update parent UI state`() = runTest { + val client = FakeWorkspaceClient().apply { + setSessions( + FakeWorkspaceClient.sessionDto(id = "parent"), + FakeWorkspaceClient.sessionDto(id = "child", parentID = "parent"), + ) + } + val repository = repository(client) + repository.refresh() + val permission = childPermission() + val question = QuestionRequest(id = "q-child", sessionID = "child", questions = emptyList()) + + repository.acceptEvent(OpenCodeEvent.PermissionRequested(permission)) + repository.acceptEvent(OpenCodeEvent.QuestionAsked(question)) + + val parentState = repository.sessionUiState(SessionId("parent")).value + assertEquals(permission, parentState.pendingPermissionsByCallId["call-child"]) + assertEquals(question, parentState.pendingQuestion) + assertTrue(repository.sessionUiState(SessionId("child")).value.pendingPermissionsByCallId.isEmpty()) + assertNull(repository.sessionUiState(SessionId("child")).value.pendingQuestion) + } + + @Test + fun `refresh replaces stale hydrated child ownership`() = runTest { + val client = FakeWorkspaceClient().apply { + setSessions( + FakeWorkspaceClient.sessionDto(id = "parent"), + FakeWorkspaceClient.sessionDto(id = "child", parentID = "parent"), + ) + } + val repository = repository(client) + repository.refresh() + client.setSessions(FakeWorkspaceClient.sessionDto(id = "child")) + repository.refresh() + val permission = childPermission() + + repository.acceptEvent(OpenCodeEvent.PermissionRequested(permission)) + + val childPermissions = repository.sessionUiState(SessionId("child")).value.pendingPermissionsByCallId + assertEquals(permission, childPermissions["call-child"]) + assertTrue(repository.sessionUiState(SessionId("parent")).value.pendingPermissionsByCallId.isEmpty()) + } + + @Test + fun `deleting parent clears child ownership`() = runTest { + val repository = repository(FakeWorkspaceClient()) + repository.acceptEvent(OpenCodeEvent.SessionCreated(session("parent"))) + repository.acceptEvent(OpenCodeEvent.SessionCreated(session("child").copy(parentID = "parent"))) + repository.acceptEvent(OpenCodeEvent.SessionDeleted(session("parent"))) + val question = QuestionRequest(id = "q-child", sessionID = "child", questions = emptyList()) + + repository.acceptEvent(OpenCodeEvent.QuestionAsked(question)) + + assertEquals(question, repository.sessionUiState(SessionId("child")).value.pendingQuestion) + assertNull(repository.sessionUiState(SessionId("parent")).value.pendingQuestion) + } + + private fun TestScope.repository(client: FakeWorkspaceClient) = SessionRepositoryImpl( + client, + nowMs = { 0L }, + dispatcher = StandardTestDispatcher(testScheduler), + ) + + private fun childPermission() = Permission( + id = "per_child", + type = "bash", + patterns = listOf("ls"), + sessionID = "child", + messageID = "msg-child", + callID = "call-child", + metadata = JsonObject(emptyMap()), + always = emptyList(), + ) + + private fun session(id: String): Session = Session( + id = id, + projectID = "project-$id", + directory = "/workspace", + title = id, + version = "1", + createdAt = 1L, + updatedAt = 1L, + ) +} diff --git a/app/src/test/java/dev/blazelight/p4oc/data/session/SessionReducerTest.kt b/app/src/test/java/dev/blazelight/p4oc/data/session/SessionReducerTest.kt index 711fe9a4..3b00aa10 100644 --- a/app/src/test/java/dev/blazelight/p4oc/data/session/SessionReducerTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/data/session/SessionReducerTest.kt @@ -2,6 +2,7 @@ package dev.blazelight.p4oc.data.session import dev.blazelight.p4oc.domain.model.OpenCodeEvent import dev.blazelight.p4oc.domain.model.Session +import dev.blazelight.p4oc.domain.model.SessionStatus import dev.blazelight.p4oc.domain.server.ServerRef import dev.blazelight.p4oc.domain.session.SessionId import dev.blazelight.p4oc.domain.session.WorkspaceSession @@ -56,11 +57,54 @@ class SessionReducerTest { @Test fun `session deleted removes session`() { - val initial = Snapshot(mapOf("gone" to workspaceSession("gone"))) + val initial = Snapshot( + sessions = mapOf("gone" to workspaceSession("gone")), + statuses = mapOf("gone" to SessionStatus.Busy), + ) val result = reducer.reduce(initial, OpenCodeEvent.SessionDeleted(session("gone"))) assertFalse(result.sessions.containsKey("gone")) + assertFalse(result.statuses.containsKey("gone")) + } + + @Test + fun `session status changed updates status`() { + val result = reducer.reduce( + Snapshot(statuses = mapOf("session" to SessionStatus.Idle)), + OpenCodeEvent.SessionStatusChanged("session", SessionStatus.Busy), + ) + + assertEquals(SessionStatus.Busy, result.statuses["session"]) + } + + @Test + fun `session idle updates status to idle`() { + val result = reducer.reduce( + Snapshot(statuses = mapOf("session" to SessionStatus.Busy)), + OpenCodeEvent.SessionIdle("session"), + ) + + assertEquals(SessionStatus.Idle, result.statuses["session"]) + } + + @Test + fun `session error updates status to idle`() { + val result = reducer.reduce( + Snapshot(statuses = mapOf("session" to SessionStatus.Busy)), + OpenCodeEvent.SessionError("session", error = null), + ) + + assertEquals(SessionStatus.Idle, result.statuses["session"]) + } + + @Test + fun `session error without session id leaves snapshot unchanged`() { + val initial = Snapshot(statuses = mapOf("session" to SessionStatus.Busy)) + + val result = reducer.reduce(initial, OpenCodeEvent.SessionError(sessionID = null, error = null)) + + assertSame(initial, result) } @Test diff --git a/app/src/test/java/dev/blazelight/p4oc/data/session/SessionRepositoryImplTest.kt b/app/src/test/java/dev/blazelight/p4oc/data/session/SessionRepositoryImplTest.kt index 1fabc737..42e1e1c3 100644 --- a/app/src/test/java/dev/blazelight/p4oc/data/session/SessionRepositoryImplTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/data/session/SessionRepositoryImplTest.kt @@ -19,6 +19,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.JsonObject import org.junit.Assert.assertEquals @@ -30,6 +31,7 @@ import org.junit.Assert.assertTrue import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) +@Suppress("LargeClass") class SessionRepositoryImplTest { @Test fun `prewarm twice returns same Deferred`() = runTest { @@ -361,6 +363,29 @@ class SessionRepositoryImplTest { assertTrue(repository.state.value.snapshot.sessions.containsKey("missed")) } + @Test + fun `project events coalesce and authoritatively replace start work projects`() = runTest { + val client = FakeWorkspaceClient().apply { + projects = listOf(FakeWorkspaceClient.projectDto("old", "/repo/old")) + } + val repository = SessionRepositoryImpl( + client, + nowMs = { testScheduler.currentTime }, + dispatcher = StandardTestDispatcher(testScheduler), + ) + repository.refresh() + client.projects = listOf(FakeWorkspaceClient.projectDto("new", "/repo/new")) + val callsBeforeEvents = client.listProjectsCalls + + repository.acceptEvent(OpenCodeEvent.ProjectDirectoriesUpdated("old")) + repository.acceptEvent(OpenCodeEvent.ProjectDirectoriesUpdated("old")) + advanceTimeBy(151) + advanceUntilIdle() + + assertEquals(callsBeforeEvents + 1, client.listProjectsCalls) + assertEquals(listOf("new"), repository.state.value.snapshot.projects.map { it.id }) + } + @Test fun `session event during reconnect hydrate replays over hydrated snapshot`() = runTest { val client = FakeWorkspaceClient().apply { @@ -388,6 +413,38 @@ class SessionRepositoryImplTest { assertTrue(sessions.containsKey("streamed")) } + @Test + fun `older hydration cannot overwrite delete and recreate from newer hydration`() = runTest { + val oldHydrationGate = CompletableDeferred() + val client = FakeWorkspaceClient().apply { + projects = emptyList() + setSessions(FakeWorkspaceClient.sessionDto(id = "same", title = "Old")) + statusBlocker = oldHydrationGate + } + val repository = SessionRepositoryImpl( + client, + nowMs = { testScheduler.currentTime }, + dispatcher = StandardTestDispatcher(testScheduler), + ) + + val olderHydration = async { repository.refresh() } + runCurrent() + repository.acceptEvent(OpenCodeEvent.SessionDeleted(session("same").copy(title = "Old"))) + repository.acceptEvent(OpenCodeEvent.SessionCreated(session("same").copy(title = "Recreated"))) + + client.setSessions(FakeWorkspaceClient.sessionDto(id = "same", title = "Recreated")) + client.statusBlocker = null + repository.refresh() + assertEquals("Recreated", repository.state.value.snapshot.sessions.getValue("same").session.title) + + oldHydrationGate.complete(Unit) + olderHydration.await() + + val finalSession = repository.state.value.snapshot.sessions.getValue("same").session + assertEquals("Recreated", finalSession.title) + assertEquals(1L, finalSession.createdAt) + } + @Test fun `connected event recovers missed pending permissions for observed sessions`() = runTest { val client = FakeWorkspaceClient().apply { @@ -457,13 +514,56 @@ class SessionRepositoryImplTest { repository.sessionUiState(SessionId("s1")) repository.acceptEvent(OpenCodeEvent.Connected) advanceUntilIdle() - assertTrue(repository.sessionUiState(SessionId("s1")).value.pendingPermissionsByCallId.isNotEmpty()) + assertTrue( + repository.sessionUiState(SessionId("s1")) + .value.pendingPermissionsByCallId.isNotEmpty() + ) client.permissionsBySession = emptyMap() repository.acceptEvent(OpenCodeEvent.Connected) advanceUntilIdle() - assertTrue(repository.sessionUiState(SessionId("s1")).value.pendingPermissionsByCallId.isEmpty()) + assertTrue( + repository.sessionUiState(SessionId("s1")) + .value.pendingPermissionsByCallId.isEmpty() + ) + } + + @Test + fun `permission reconciliation preserves permission arriving during request`() = runTest { + val client = FakeWorkspaceClient().apply { + projects = emptyList() + listPermissionsBlocker = CompletableDeferred() + } + val repository = + SessionRepositoryImpl( + client, + nowMs = { testScheduler.currentTime }, + dispatcher = StandardTestDispatcher(testScheduler) + ) + repository.sessionUiState(SessionId("s1")) + + repository.acceptEvent(OpenCodeEvent.Connected) + runCurrent() + repository.acceptEvent( + OpenCodeEvent.PermissionRequested( + Permission( + id = "per_concurrent", + type = "bash", + patterns = listOf("pwd"), + sessionID = "s1", + messageID = "msg-1", + callID = "call-concurrent", + metadata = JsonObject(emptyMap()), + always = emptyList(), + ) + ) + ) + client.listPermissionsBlocker?.complete(Unit) + advanceUntilIdle() + + val permissions = repository.sessionUiState(SessionId("s1")).value.pendingPermissionsByCallId + assertEquals("per_concurrent", permissions.getValue("call-concurrent").id) } @Test @@ -623,12 +723,40 @@ class SessionRepositoryImplTest { repository.acceptEvent(OpenCodeEvent.PermissionRequested(permission)) val uiState = repository.sessionUiState(SessionId("s1")).value + assertEquals(permission, uiState.pendingPermissionsByCallId["permission:per_1"]) assertTrue( "Permission without callID should still be visible in session state", uiState.pendingPermissionsByCallId.values.any { it.id == "per_1" } ) } + @Test + fun `blank callID uses stable permission request identity`() = runTest { + val repository = SessionRepositoryImpl( + FakeWorkspaceClient().apply { projects = emptyList() }, + nowMs = { testScheduler.currentTime }, + dispatcher = StandardTestDispatcher(testScheduler), + ) + val permission = Permission( + id = "per_blank", + type = "bash", + patterns = listOf("pwd"), + sessionID = "s1", + messageID = "", + callID = "", + metadata = JsonObject(emptyMap()), + always = emptyList(), + ) + + repository.acceptEvent(OpenCodeEvent.PermissionRequested(permission)) + + assertEquals( + permission, + repository.sessionUiState(SessionId("s1")).value + .pendingPermissionsByCallId["permission:per_blank"], + ) + } + private fun session(id: String): Session = Session( id = id, projectID = "project-$id", diff --git a/app/src/test/java/dev/blazelight/p4oc/data/session/SessionRepositoryLeaseTest.kt b/app/src/test/java/dev/blazelight/p4oc/data/session/SessionRepositoryLeaseTest.kt new file mode 100644 index 00000000..8b92c361 --- /dev/null +++ b/app/src/test/java/dev/blazelight/p4oc/data/session/SessionRepositoryLeaseTest.kt @@ -0,0 +1,89 @@ +package dev.blazelight.p4oc.data.session + +import dev.blazelight.p4oc.domain.model.Message +import dev.blazelight.p4oc.domain.model.OpenCodeEvent +import dev.blazelight.p4oc.domain.model.SessionStatus +import dev.blazelight.p4oc.domain.model.TokenUsage +import dev.blazelight.p4oc.domain.session.SessionId +import dev.blazelight.p4oc.fakes.FakeWorkspaceClient +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class SessionRepositoryLeaseTest { + @Test + fun `session cache remains until final consumer releases then reopening starts empty`() = runTest { + val repository = repository(StandardTestDispatcher(testScheduler)) + val sessionId = SessionId(SESSION_ID) + val firstLease = repository.acquireSession(sessionId) + val secondLease = repository.acquireSession(sessionId) + val originalMessages = repository.messages(sessionId) + val originalUiState = repository.sessionUiState(sessionId) + + repository.acceptEvent(OpenCodeEvent.MessageUpdated(assistantMessage("m1"))) + repository.acceptEvent(OpenCodeEvent.SessionStatusChanged(SESSION_ID, SessionStatus.Busy)) + assertEquals(1, originalMessages.value.size) + assertEquals(SessionStatus.Busy, originalUiState.value.status) + + firstLease.close() + assertEquals(1, repository.messages(sessionId).value.size) + assertEquals(SessionStatus.Busy, repository.sessionUiState(sessionId).value.status) + + secondLease.close() + assertTrue(originalMessages.value.isEmpty()) + assertEquals(SessionUiState(), originalUiState.value) + + val reopenedLease = repository.acquireSession(sessionId) + val reopenedMessages = repository.messages(sessionId) + assertTrue(reopenedMessages.value.isEmpty()) + assertFalse(originalMessages === reopenedMessages) + + repository.acceptEvent(OpenCodeEvent.MessageUpdated(assistantMessage("m2"))) + assertEquals(listOf("m2"), reopenedMessages.value.map { it.message.id }) + reopenedLease.close() + } + + @Test + fun `closing a session lease twice releases only its own reference`() = runTest { + val repository = repository(StandardTestDispatcher(testScheduler)) + val sessionId = SessionId(SESSION_ID) + val firstLease = repository.acquireSession(sessionId) + val secondLease = repository.acquireSession(sessionId) + repository.acceptEvent(OpenCodeEvent.MessageUpdated(assistantMessage("m1"))) + + firstLease.close() + firstLease.close() + + assertEquals(1, repository.messages(sessionId).value.size) + secondLease.close() + assertTrue(repository.messages(sessionId).value.isEmpty()) + } + + private fun repository(dispatcher: TestDispatcher) = SessionRepositoryImpl( + FakeWorkspaceClient(), + dispatcher = dispatcher, + ) + + private fun assistantMessage(id: String) = Message.Assistant( + id = id, + sessionID = SESSION_ID, + createdAt = 1L, + parentID = "", + providerID = "provider", + modelID = "model", + mode = "chat", + agent = "assistant", + cost = 0.0, + tokens = TokenUsage(input = 0, output = 0), + ) + + private companion object { + const val SESSION_ID = "s1" + } +} diff --git a/app/src/test/java/dev/blazelight/p4oc/data/session/SessionRepositoryMessageStateTest.kt b/app/src/test/java/dev/blazelight/p4oc/data/session/SessionRepositoryMessageStateTest.kt index f90db12b..0e528e07 100644 --- a/app/src/test/java/dev/blazelight/p4oc/data/session/SessionRepositoryMessageStateTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/data/session/SessionRepositoryMessageStateTest.kt @@ -9,6 +9,7 @@ import dev.blazelight.p4oc.domain.session.SessionId import dev.blazelight.p4oc.fakes.FakeWorkspaceClient import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -75,7 +76,7 @@ class SessionRepositoryMessageStateTest { } @Test - fun `part updated appends delta and marks streaming`() = runTest { + fun `part updated uses authoritative text snapshot and marks streaming`() = runTest { val repository = repository() repository.acceptEvent(OpenCodeEvent.MessageUpdated(assistantMessage(id = "m1", createdAt = 100))) repository.acceptEvent( @@ -83,7 +84,10 @@ class SessionRepositoryMessageStateTest { ) repository.acceptEvent( - OpenCodeEvent.MessagePartUpdated(textPart(id = "p1", messageId = "m1", text = "ignored"), delta = " world") + OpenCodeEvent.MessagePartUpdated( + textPart(id = "p1", messageId = "m1", text = "Hello world"), + delta = " world", + ) ) val part = repository.messages(sessionId).value.single().parts.single() as Part.Text @@ -91,6 +95,79 @@ class SessionRepositoryMessageStateTest { assertTrue(part.isStreaming) } + @Test + fun `authoritative part update repairs duplicated delta without dropping metadata or sibling parts`() = runTest { + val repository = repository() + repository.acceptEvent(OpenCodeEvent.MessageUpdated(assistantMessage(id = "m1", createdAt = 100))) + val metadata = buildJsonObject { put("source", JsonPrimitive("server")) } + repository.acceptEvent( + OpenCodeEvent.MessagePartUpdated( + textPart(id = "p1", messageId = "m1", text = "Hello").copy(metadata = metadata), + delta = null, + ) + ) + repository.acceptEvent(OpenCodeEvent.MessagePartUpdated(toolPart(id = "p2", messageId = "m1"), delta = null)) + repository.acceptEvent( + OpenCodeEvent.MessagePartDelta( + sessionID = "s1", + messageID = "m1", + partID = "p1", + field = "text", + delta = " world", + ) + ) + repository.acceptEvent( + OpenCodeEvent.MessagePartDelta( + sessionID = "s1", + messageID = "m1", + partID = "p1", + field = "text", + delta = " world", + ) + ) + assertEquals( + "Hello world world", + (repository.messages(sessionId).value.single().parts.first() as Part.Text).text, + ) + + repository.acceptEvent( + OpenCodeEvent.MessagePartUpdated( + textPart(id = "p1", messageId = "m1", text = "Hello world"), + delta = " world", + ) + ) + + val parts = repository.messages(sessionId).value.single().parts + val corrected = parts.first() as Part.Text + assertEquals("Hello world", corrected.text) + assertEquals(metadata, corrected.metadata) + assertEquals(listOf("p1", "p2"), parts.map { it.id }) + } + + @Test + fun `authoritative reasoning update repairs missed delta and preserves omitted metadata`() = runTest { + val repository = repository() + repository.acceptEvent(OpenCodeEvent.MessageUpdated(assistantMessage(id = "m1", createdAt = 100))) + val metadata = buildJsonObject { put("model", JsonPrimitive("reasoner")) } + repository.acceptEvent( + OpenCodeEvent.MessagePartUpdated( + reasoningPart(id = "p1", messageId = "m1", text = "Think").copy(metadata = metadata), + delta = null, + ) + ) + + repository.acceptEvent( + OpenCodeEvent.MessagePartUpdated( + reasoningPart(id = "p1", messageId = "m1", text = "Think carefully"), + delta = " carefully", + ) + ) + + val corrected = repository.messages(sessionId).value.single().parts.single() as Part.Reasoning + assertEquals("Think carefully", corrected.text) + assertEquals(metadata, corrected.metadata) + } + @Test fun `clear streaming flags sets all text parts non-streaming`() = runTest { val repository = repository() @@ -125,6 +202,19 @@ class SessionRepositoryMessageStateTest { assertTrue(repository.messages(sessionId).value.isEmpty()) } + @Test + fun `session deletion clears and removes retained message state`() = runTest { + val repository = repository() + val retainedMessages = repository.messages(sessionId) + repository.acceptEvent(OpenCodeEvent.MessageUpdated(assistantMessage(id = "m1", createdAt = 100))) + assertEquals(listOf("m1"), retainedMessages.value.map { it.message.id }) + + repository.acceptEvent(OpenCodeEvent.SessionDeleted(session("s1"))) + + assertTrue(retainedMessages.value.isEmpty()) + assertTrue(repository.messages(sessionId).value.isEmpty()) + } + @Test fun `messages emit sorted by createdAt`() = runTest { val repository = repository() @@ -149,6 +239,16 @@ class SessionRepositoryMessageStateTest { private fun repository(): SessionRepositoryImpl = SessionRepositoryImpl(FakeWorkspaceClient()) + private fun session(id: String) = dev.blazelight.p4oc.domain.model.Session( + id = id, + projectID = "project-$id", + directory = "/workspace", + title = id, + version = "1", + createdAt = 1L, + updatedAt = 1L, + ) + private fun assistantMessage(id: String, createdAt: Long): Message.Assistant = Message.Assistant( id = id, sessionID = "s1", @@ -175,6 +275,13 @@ class SessionRepositoryMessageStateTest { isStreaming = isStreaming, ) + private fun reasoningPart(id: String, messageId: String, text: String): Part.Reasoning = Part.Reasoning( + id = id, + sessionID = "s1", + messageID = messageId, + text = text, + ) + private fun toolPart(id: String, messageId: String): Part.Tool = Part.Tool( id = id, sessionID = "s1", diff --git a/app/src/test/java/dev/blazelight/p4oc/data/session/SessionRepositoryProviderTest.kt b/app/src/test/java/dev/blazelight/p4oc/data/session/SessionRepositoryProviderTest.kt index d71fafaa..b0bdc4a2 100644 --- a/app/src/test/java/dev/blazelight/p4oc/data/session/SessionRepositoryProviderTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/data/session/SessionRepositoryProviderTest.kt @@ -1,7 +1,8 @@ package dev.blazelight.p4oc.data.session -import dev.blazelight.p4oc.core.network.ConnectionManager +import dev.blazelight.p4oc.core.network.ConnectionState import dev.blazelight.p4oc.core.network.OpenCodeApi +import dev.blazelight.p4oc.core.network.ServerConnectionRegistry import dev.blazelight.p4oc.data.remote.mapper.MessageMapper import dev.blazelight.p4oc.data.server.ActiveServerApiProvider import dev.blazelight.p4oc.domain.model.OpenCodeEvent @@ -14,6 +15,7 @@ import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.StandardTestDispatcher @@ -131,8 +133,12 @@ class SessionRepositoryProviderTest { ): SessionRepositoryProvider = SessionRepositoryProvider( activeServerApiProvider = ActiveServerApiProvider { _, _ -> mockk(relaxed = true) }, messageMapper = MessageMapper(Json { ignoreUnknownKeys = true }), - connectionManager = mockk { - every { this@mockk.scopedEvents } returns scopedEvents + serverConnectionRegistry = mockk { + every { events(any()) } returns scopedEvents + every { connectionState(any(), ServerGeneration(1)) } returns + MutableStateFlow(ConnectionState.Connected) + every { connectionState(any(), ServerGeneration(2)) } returns + MutableStateFlow(ConnectionState.Connected) }, dispatcher = dispatcher, ) diff --git a/app/src/test/java/dev/blazelight/p4oc/data/workspace/WorkspaceClientTest.kt b/app/src/test/java/dev/blazelight/p4oc/data/workspace/WorkspaceClientTest.kt index 87e21c76..75c8f7ba 100644 --- a/app/src/test/java/dev/blazelight/p4oc/data/workspace/WorkspaceClientTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/data/workspace/WorkspaceClientTest.kt @@ -1,6 +1,14 @@ package dev.blazelight.p4oc.data.workspace +import dev.blazelight.p4oc.core.network.ConnectionState import dev.blazelight.p4oc.core.network.OpenCodeApi +import dev.blazelight.p4oc.data.remote.dto.ConfigDto +import dev.blazelight.p4oc.data.remote.dto.ProvidersResponseDto +import dev.blazelight.p4oc.data.remote.dto.QuestionDto +import dev.blazelight.p4oc.data.remote.dto.QuestionOptionDto +import dev.blazelight.p4oc.data.remote.dto.QuestionReplyRequest +import dev.blazelight.p4oc.data.remote.dto.QuestionRequestDto +import dev.blazelight.p4oc.data.remote.dto.QuestionV2RequestListResponseDto import dev.blazelight.p4oc.data.server.ActiveServerApiProvider import dev.blazelight.p4oc.data.server.StaleWorkspaceClientException import dev.blazelight.p4oc.domain.server.ServerGeneration @@ -9,16 +17,170 @@ import dev.blazelight.p4oc.domain.workspace.Workspace import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.ResponseBody.Companion.toResponseBody import org.junit.Assert.assertEquals import org.junit.Assert.fail import org.junit.Test +import retrofit2.HttpException +import retrofit2.Response +import java.net.SocketTimeoutException class WorkspaceClientTest { + @Test + fun `question operations use legacy endpoints without v2 requests`() = runTest { + val api = mockk() + val question = questionRequest("ses_1") + val otherQuestion = questionRequest("ses_other") + val reply = QuestionReplyRequest(listOf(listOf("Yes"))) + coEvery { api.listPendingQuestions("/repo", null) } returns Response.success(listOf(question, otherQuestion)) + coEvery { api.respondToQuestion("que_1", reply, "/repo", null) } returns Response.success(true) + coEvery { api.rejectQuestion("que_1", "/repo", null) } returns Response.success(true) + val client = questionClient(api) + + assertEquals(listOf(question), client.listSessionQuestions("ses_1")) + assertEquals(true, client.respondToQuestion("ses_1", "que_1", reply)) + assertEquals(true, client.rejectQuestion("ses_1", "que_1")) + + coVerify(exactly = 0) { api.listSessionQuestionsV2(any()) } + coVerify(exactly = 0) { api.respondToQuestionV2(any(), any(), any()) } + coVerify(exactly = 0) { api.rejectQuestionV2(any(), any()) } + } + + @Test + fun `question operations use v2 only when legacy endpoint is unavailable`() = runTest { + val api = mockk() + val question = questionRequest("ses_1") + val reply = QuestionReplyRequest(listOf(listOf("Yes"))) + coEvery { api.listPendingQuestions("/repo", null) } returns + Response.error(404, "{}".toResponseBody("application/json".toMediaType())) + coEvery { api.respondToQuestion("que_1", reply, "/repo", null) } returns + Response.error(404, "{}".toResponseBody("application/json".toMediaType())) + coEvery { api.rejectQuestion("que_1", "/repo", null) } returns + Response.error(404, "{}".toResponseBody("application/json".toMediaType())) + coEvery { api.listSessionQuestionsV2("ses_1") } returns + Response.success(QuestionV2RequestListResponseDto(listOf(question))) + coEvery { api.respondToQuestionV2("ses_1", "que_1", reply) } returns Response.success(Unit) + coEvery { api.rejectQuestionV2("ses_1", "que_1") } returns Response.success(Unit) + val client = questionClient(api) + + assertEquals(listOf(question), client.listSessionQuestions("ses_1")) + assertEquals(true, client.respondToQuestion("ses_1", "que_1", reply)) + assertEquals(true, client.rejectQuestion("ses_1", "que_1")) + } + + @Test + fun `question legacy http errors propagate without v2 fallback`() = runTest { + val api = mockk() + val reply = QuestionReplyRequest(listOf(listOf("Yes"))) + coEvery { api.respondToQuestion("que_1", reply, "/repo", null) } returns + Response.error(401, "{}".toResponseBody("application/json".toMediaType())) + coEvery { api.rejectQuestion("que_1", "/repo", null) } returns + Response.error(422, "{}".toResponseBody("application/json".toMediaType())) + coEvery { api.listPendingQuestions("/repo", null) } returns + Response.error(500, "{}".toResponseBody("application/json".toMediaType())) + val client = questionClient(api) + + assertHttpError(401) { client.respondToQuestion("ses_1", "que_1", reply) } + assertHttpError(422) { client.rejectQuestion("ses_1", "que_1") } + assertHttpError(500) { client.listSessionQuestions("ses_1") } + + coVerify(exactly = 0) { api.respondToQuestionV2(any(), any(), any()) } + coVerify(exactly = 0) { api.rejectQuestionV2(any(), any()) } + coVerify(exactly = 0) { api.listSessionQuestionsV2(any()) } + } + + @Test + fun `question legacy timeout propagates exactly without v2 fallback`() = runTest { + val api = mockk() + val reply = QuestionReplyRequest(listOf(listOf("Yes"))) + val timeout = SocketTimeoutException("legacy timeout") + coEvery { api.respondToQuestion("que_1", reply, "/repo", null) } throws timeout + val client = questionClient(api) + + try { + client.respondToQuestion("ses_1", "que_1", reply) + fail("Expected legacy timeout") + } catch (error: SocketTimeoutException) { + assertEquals(timeout, error) + } + + coVerify(exactly = 0) { api.respondToQuestionV2(any(), any(), any()) } + } + + @Test + fun `update current model preserves config and scopes request to workspace directory`() = runTest { + val api = mockk() + val existing = ConfigDto( + theme = "opencode", + model = "openai/gpt-4", + username = "user", + enabledProviders = listOf("openai", "anthropic"), + instructions = listOf("CONTRIBUTING.md"), + ) + val updated = existing.copy(model = "anthropic/claude-3") + coEvery { api.getConfig("/repo", null) } returns existing + coEvery { api.updateConfig(updated, "/repo", null) } returns updated + val server = ServerRef.fromEndpointKey("http://test.local") + val client = WorkspaceClient( + workspace = Workspace(server, directory = "/repo"), + generation = ServerGeneration(1L), + apiProvider = ActiveServerApiProvider { _, _ -> api }, + connectionState = MutableStateFlow(ConnectionState.Connected), + ) + + assertEquals(updated, client.updateCurrentModel("anthropic/claude-3")) + + coVerify(exactly = 1) { api.getConfig("/repo", null) } + coVerify(exactly = 1) { api.updateConfig(updated, "/repo", null) } + } + + @Test + fun `clients with identical session ids keep distinct api and connection authority`() = runTest { + val firstApi = mockk() + val secondApi = mockk() + val providers = ProvidersResponseDto(emptyList(), emptyMap(), emptyList()) + coEvery { firstApi.getProviders(any(), null) } returns providers + coEvery { secondApi.getProviders(any(), null) } returns providers + val firstState = MutableStateFlow(ConnectionState.Connected) + val secondState = MutableStateFlow(ConnectionState.Error("second unavailable")) + val firstServer = ServerRef.fromEndpointKey("http://first.test") + val secondServer = ServerRef.fromEndpointKey("http://second.test") + val provider = ActiveServerApiProvider { server, _ -> + when (server) { + firstServer -> firstApi + secondServer -> secondApi + else -> error("Unexpected server ${server.endpointKey}") + } + } + val firstClient = WorkspaceClient( + Workspace(firstServer, directory = "/same-session"), + ServerGeneration(1L), + provider, + firstState, + ) + val secondClient = WorkspaceClient( + Workspace(secondServer, directory = "/same-session"), + ServerGeneration(1L), + provider, + secondState, + ) + + firstClient.getProviders() + secondClient.getProviders() + + coVerify(exactly = 1) { firstApi.getProviders(any(), null) } + coVerify(exactly = 1) { secondApi.getProviders(any(), null) } + assertEquals(firstState.value, firstClient.connectionState.value) + assertEquals(secondState.value, secondClient.connectionState.value) + } + @Test fun `resolves api through provider on every call`() = runTest { val api = mockk() - coEvery { api.listProjects() } returns emptyList() + coEvery { api.listProjects(null, null) } returns emptyList() var activeGeneration = ServerGeneration(1L) var providerCalls = 0 val workspace = Workspace( @@ -35,6 +197,9 @@ class WorkspaceClientTest { } api }, + connectionState = kotlinx.coroutines.flow.MutableStateFlow( + dev.blazelight.p4oc.core.network.ConnectionState.Disconnected + ), ) assertEquals(emptyList(), client.listProjects()) @@ -47,6 +212,37 @@ class WorkspaceClientTest { // Expected. } assertEquals(2, providerCalls) - coVerify(exactly = 1) { api.listProjects() } + coVerify(exactly = 1) { api.listProjects(null, null) } } + + private fun questionClient(api: OpenCodeApi): WorkspaceClient { + val server = ServerRef.fromEndpointKey("http://test.local") + return WorkspaceClient( + workspace = Workspace(server, directory = "/repo"), + generation = ServerGeneration(1L), + apiProvider = ActiveServerApiProvider { _, _ -> api }, + connectionState = MutableStateFlow(ConnectionState.Connected), + ) + } + + private suspend fun assertHttpError(code: Int, block: suspend () -> Unit) { + try { + block() + fail("Expected HTTP $code") + } catch (error: HttpException) { + assertEquals(code, error.code()) + } + } + + private fun questionRequest(sessionId: String) = QuestionRequestDto( + id = if (sessionId == "ses_1") "que_1" else "que_other", + sessionID = sessionId, + questions = listOf( + QuestionDto( + header = "Confirm", + question = "Continue?", + options = listOf(QuestionOptionDto("Yes", "Continue")), + ) + ), + ) } diff --git a/app/src/test/java/dev/blazelight/p4oc/fakes/FakeSessionRepository.kt b/app/src/test/java/dev/blazelight/p4oc/fakes/FakeSessionRepository.kt index e66a4fa8..a61ce63b 100644 --- a/app/src/test/java/dev/blazelight/p4oc/fakes/FakeSessionRepository.kt +++ b/app/src/test/java/dev/blazelight/p4oc/fakes/FakeSessionRepository.kt @@ -53,13 +53,15 @@ class FakeSessionRepository( override fun sessionUiState(sessionId: SessionId): StateFlow = MutableStateFlow(SessionUiState()) + override fun acquireSession(sessionId: SessionId): AutoCloseable = AutoCloseable { } + override fun clearPermission(sessionId: SessionId, permissionId: String) = Unit override fun clearPermissionByRequestId(sessionId: SessionId, requestId: String) = Unit override fun clearQuestion(sessionId: SessionId, requestId: String?) = Unit - override suspend fun loadMessages(sessionId: SessionId, limit: Int?) = Unit + override suspend fun loadMessages(sessionId: SessionId, limit: Int): Int = 0 override fun sendMessageAsync(sessionId: SessionId, request: SendMessageRequest): Deferred> = CompletableDeferred(Result.success(Unit)) diff --git a/app/src/test/java/dev/blazelight/p4oc/fakes/FakeWorkspaceClient.kt b/app/src/test/java/dev/blazelight/p4oc/fakes/FakeWorkspaceClient.kt index 641a640a..e3c4c5c3 100644 --- a/app/src/test/java/dev/blazelight/p4oc/fakes/FakeWorkspaceClient.kt +++ b/app/src/test/java/dev/blazelight/p4oc/fakes/FakeWorkspaceClient.kt @@ -74,6 +74,7 @@ class FakeWorkspaceClient( var sendMessageBlocker: CompletableDeferred? = null var abortSessionBlocker: CompletableDeferred? = null var statusBlocker: CompletableDeferred? = null + var listPermissionsBlocker: CompletableDeferred? = null var trackStatusConcurrency: Boolean = false private val activeStatusCalls = AtomicInteger(0) var maxObservedStatusConcurrency: Int = 0 @@ -174,6 +175,7 @@ class FakeWorkspaceClient( override suspend fun listPermissions(): List { listPermissionsCalls += 1 + listPermissionsBlocker?.await() return legacyPermissions } @@ -231,10 +233,12 @@ class FakeWorkspaceClient( title: String = id, directory: String = "/workspace", updatedAt: Long = 1L, + parentID: String? = null, ): SessionDto = SessionDto( id = id, projectID = "project-$id", directory = directory, + parentID = parentID, title = title, version = "1", time = TimeDto(created = 1L, updated = updatedAt), diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModelDraftPersistenceTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModelDraftPersistenceTest.kt index c0067774..91bb5721 100644 --- a/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModelDraftPersistenceTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModelDraftPersistenceTest.kt @@ -7,12 +7,8 @@ import dev.blazelight.p4oc.core.datastore.SettingsDataStore import dev.blazelight.p4oc.core.datastore.VisualSettings import dev.blazelight.p4oc.core.haptic.HapticFeedback import dev.blazelight.p4oc.core.log.AppLog -import dev.blazelight.p4oc.core.network.Connection -import dev.blazelight.p4oc.core.network.ConnectionManager import dev.blazelight.p4oc.core.network.ConnectionState import dev.blazelight.p4oc.core.network.OpenCodeApi -import dev.blazelight.p4oc.core.network.OpenCodeEventSource -import dev.blazelight.p4oc.core.network.ServerConfig import dev.blazelight.p4oc.data.files.FileRepository import dev.blazelight.p4oc.data.files.FileRepositoryFactory import dev.blazelight.p4oc.data.remote.dto.FileNodeDto @@ -67,10 +63,8 @@ class ChatViewModelDraftPersistenceTest { @get:Rule val mainDispatcherRule = DraftPersistenceMainDispatcherRule() - private lateinit var connectionManager: ConnectionManager private lateinit var messageMapper: MessageMapper private lateinit var settingsDataStore: SettingsDataStore - private lateinit var eventSource: OpenCodeEventSource private lateinit var events: MutableSharedFlow private lateinit var api: OpenCodeApi private lateinit var workspaceClient: WorkspaceClient @@ -91,10 +85,8 @@ class ChatViewModelDraftPersistenceTest { every { AppLog.e(any(), any()) } returns Unit every { AppLog.e(any(), any(), any()) } returns Unit - connectionManager = mockk() messageMapper = MessageMapper(Json { ignoreUnknownKeys = true }) settingsDataStore = mockk() - eventSource = mockk() events = MutableSharedFlow(extraBufferCapacity = 32) api = mockk(relaxed = true) workspaceClient = WorkspaceClient( @@ -104,20 +96,8 @@ class ChatViewModelDraftPersistenceTest { ), generation = ServerGeneration(0L), apiProvider = ActiveServerApiProvider { _, _ -> api }, + connectionState = MutableStateFlow(ConnectionState.Disconnected), ) - every { connectionManager.connectionState } returns MutableStateFlow(ConnectionState.Disconnected) - every { connectionManager.getApi() } returns api - every { connectionManager.getEventSource() } returns eventSource - every { eventSource.events } returns MutableSharedFlow(extraBufferCapacity = 32) - every { connectionManager.connection } returns MutableStateFlow( - Connection( - config = ServerConfig.LOCAL_DEFAULT, - generation = ServerGeneration(0L), - api = api, - eventSource = eventSource, - ) - ) - every { connectionManager.scopedEvents } returns events every { settingsDataStore.favoriteModels } returns flowOf(emptySet()) every { settingsDataStore.recentModels } returns flowOf(emptyList()) every { settingsDataStore.chatSettings } returns flowOf(ChatSettings()) @@ -144,6 +124,21 @@ class ChatViewModelDraftPersistenceTest { assertEquals("unsent draft", savedStateHandle.get("chat_draft_text")) } + @Test + fun updateInput_oversizedDraftRemovesPersistenceAndSmallerDraftRecovers() = runTest { + val savedStateHandle = SavedStateHandle(mapOf(Screen.Chat.ARG_SESSION_ID to "session-1")) + val vm = createViewModel(savedStateHandle) + + vm.updateInput("x".repeat(70_000)) + + assertEquals(70_000, vm.uiState.value.inputText.length) + assertNull(savedStateHandle.get("chat_draft_text")) + + vm.updateInput("small again") + + assertEquals("small again", savedStateHandle.get("chat_draft_text")) + } + @Test fun createViewModel_restoresDraftTextFromSavedStateHandle() = runTest { val savedStateHandle = SavedStateHandle( @@ -186,9 +181,35 @@ class ChatViewModelDraftPersistenceTest { assertEquals(listOf("src/Main.kt"), vm.filePickerManager.attachedFiles.value.map { it.path }) } + @Test + fun attachments_oversizedJsonRemovesPersistenceAndSmallerListRecovers() = runTest { + val savedStateHandle = SavedStateHandle(mapOf(Screen.Chat.ARG_SESSION_ID to "session-1")) + val vm = createViewModel(savedStateHandle) + val oversized = SelectedFile( + path = "src/${"x".repeat(70_000)}.kt", + name = "Large.kt", + mimeType = "text/x-kotlin", + ) + + vm.filePickerManager.restoreAttachedFiles(listOf(oversized)) + advanceUntilIdle() + + assertEquals(oversized, vm.filePickerManager.attachedFiles.value.single()) + assertNull(savedStateHandle.get("chat_attached_files")) + + vm.filePickerManager.detachFile(oversized.path) + vm.filePickerManager.restoreAttachedFiles( + listOf(SelectedFile(path = "src/Small.kt", name = "Small.kt", mimeType = "text/x-kotlin")) + ) + advanceUntilIdle() + + val persisted = savedStateHandle.get("chat_attached_files") + assertEquals("src/Small.kt", Json.decodeFromString>(persisted!!).single().path) + } + @Test fun createViewModel_restoresAvailableAttachmentAsSendable() = runTest { - coEvery { api.listFiles("src", "/test") } returns listOf( + coEvery { api.listFiles("src", "/test", null) } returns listOf( FileNodeDto( name = "Main.kt", path = "src/Main.kt", @@ -196,7 +217,7 @@ class ChatViewModelDraftPersistenceTest { type = "file", ) ) - coEvery { api.sendMessageAsync(any(), any(), any()) } returns Unit + coEvery { api.sendMessageAsync(any(), any(), any(), null) } returns Unit val savedStateHandle = SavedStateHandle( mapOf( Screen.Chat.ARG_SESSION_ID to "session-1", @@ -214,13 +235,13 @@ class ChatViewModelDraftPersistenceTest { vm.sendMessage() advanceUntilIdle() - coVerify(exactly = 1) { api.sendMessageAsync("session-1", any(), "/test") } + coVerify(exactly = 1) { api.sendMessageAsync("session-1", any(), "/test", null) } } @Test fun createViewModel_marksRestoredMissingAttachmentUnavailableAndBlocksSend() = runTest { - coEvery { api.listFiles("src", "/test") } returns emptyList() - coEvery { api.sendMessageAsync(any(), any(), any()) } returns Unit + coEvery { api.listFiles("src", "/test", null) } returns emptyList() + coEvery { api.sendMessageAsync(any(), any(), any(), null) } returns Unit val savedStateHandle = SavedStateHandle( mapOf( Screen.Chat.ARG_SESSION_ID to "session-1", @@ -239,15 +260,15 @@ class ChatViewModelDraftPersistenceTest { vm.sendMessage() advanceUntilIdle() - coVerify(exactly = 0) { api.sendMessageAsync(any(), any(), any()) } + coVerify(exactly = 0) { api.sendMessageAsync(any(), any(), any(), null) } assertEquals("please read this", vm.uiState.value.inputText) assertEquals(listOf("src/Missing.kt"), vm.filePickerManager.attachedFiles.value.map { it.path }) } @Test fun detachFile_removingUnavailableRestoredAttachmentClearsSendBlocker() = runTest { - coEvery { api.listFiles("src", "/test") } returns emptyList() - coEvery { api.sendMessageAsync(any(), any(), any()) } returns Unit + coEvery { api.listFiles("src", "/test", null) } returns emptyList() + coEvery { api.sendMessageAsync(any(), any(), any(), null) } returns Unit val savedStateHandle = SavedStateHandle( mapOf( Screen.Chat.ARG_SESSION_ID to "session-1", @@ -264,12 +285,12 @@ class ChatViewModelDraftPersistenceTest { advanceUntilIdle() assertTrue(vm.filePickerManager.attachedFiles.value.isEmpty()) - coVerify(exactly = 1) { api.sendMessageAsync("session-1", any(), "/test") } + coVerify(exactly = 1) { api.sendMessageAsync("session-1", any(), "/test", null) } } @Test fun createViewModel_marksRestoredAttachmentUnavailableWhenValidationFails() = runTest { - coEvery { api.listFiles("src", "/test") } throws HttpException( + coEvery { api.listFiles("src", "/test", null) } throws HttpException( Response.error(403, "forbidden".toResponseBody(null)) ) val savedStateHandle = SavedStateHandle( @@ -291,8 +312,8 @@ class ChatViewModelDraftPersistenceTest { fun sendMessage_successClearsPersistedDraftAndAttachments() = runTest { val savedStateHandle = SavedStateHandle(mapOf(Screen.Chat.ARG_SESSION_ID to "session-1")) val vm = createViewModel(savedStateHandle) - coEvery { api.sendMessageAsync(any(), any(), any()) } returns Unit - coEvery { api.listFiles("src", "/test") } returns listOf( + coEvery { api.sendMessageAsync(any(), any(), any(), null) } returns Unit + coEvery { api.listFiles("src", "/test", null) } returns listOf( FileNodeDto( name = "Main.kt", path = "src/Main.kt", @@ -337,7 +358,6 @@ class ChatViewModelDraftPersistenceTest { workspaceClient = workspaceClient, sessionRepository = sessionRepository, uploadCoordinator = testUploadCoordinator(fileRepository), - connectionManager = connectionManager, settingsDataStore = settingsDataStore, hapticFeedback = hapticFeedback, ).also { advanceUntilIdle() } diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModelTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModelTest.kt index 86369f62..2ef57f17 100644 --- a/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModelTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/ChatViewModelTest.kt @@ -7,12 +7,8 @@ import dev.blazelight.p4oc.core.datastore.SettingsDataStore import dev.blazelight.p4oc.core.datastore.VisualSettings import dev.blazelight.p4oc.core.haptic.HapticFeedback import dev.blazelight.p4oc.core.log.AppLog -import dev.blazelight.p4oc.core.network.Connection -import dev.blazelight.p4oc.core.network.ConnectionManager import dev.blazelight.p4oc.core.network.ConnectionState import dev.blazelight.p4oc.core.network.OpenCodeApi -import dev.blazelight.p4oc.core.network.OpenCodeEventSource -import dev.blazelight.p4oc.core.network.ServerConfig import dev.blazelight.p4oc.data.files.FileRepository import dev.blazelight.p4oc.data.files.FileRepositoryFactory import dev.blazelight.p4oc.data.remote.dto.CommandDto @@ -90,10 +86,8 @@ class ChatViewModelTest { @get:Rule val mainDispatcherRule = ChatViewModelMainDispatcherRule() - private lateinit var connectionManager: ConnectionManager private lateinit var messageMapper: MessageMapper private lateinit var settingsDataStore: SettingsDataStore - private lateinit var eventSource: OpenCodeEventSource private lateinit var events: MutableSharedFlow private lateinit var api: OpenCodeApi private lateinit var workspaceClient: WorkspaceClient @@ -114,10 +108,8 @@ class ChatViewModelTest { every { AppLog.e(any(), any()) } returns Unit every { AppLog.e(any(), any(), any()) } returns Unit - connectionManager = mockk() messageMapper = MessageMapper(Json { ignoreUnknownKeys = true }) settingsDataStore = mockk() - eventSource = mockk() events = MutableSharedFlow(extraBufferCapacity = 32) api = mockk(relaxed = true) workspaceClient = WorkspaceClient( @@ -127,23 +119,8 @@ class ChatViewModelTest { ), generation = ServerGeneration(0L), apiProvider = ActiveServerApiProvider { _, _ -> api }, + connectionState = MutableStateFlow(ConnectionState.Disconnected), ) - every { connectionManager.connectionState } returns MutableStateFlow(ConnectionState.Disconnected) - every { connectionManager.getApi() } returns api - every { connectionManager.getEventSource() } returns eventSource - every { eventSource.events } returns MutableSharedFlow(extraBufferCapacity = 32) - - // Mock connectionManager.connection with a Connection wrapping the test eventSource - // so that observeEvents() can flatMapLatest into the events flow - val testConnection = Connection( - config = ServerConfig.LOCAL_DEFAULT, - generation = ServerGeneration(0L), - api = api, - eventSource = eventSource - ) - every { connectionManager.connection } returns MutableStateFlow(testConnection) - every { connectionManager.scopedEvents } returns events - every { settingsDataStore.favoriteModels } returns flowOf(emptySet()) every { settingsDataStore.recentModels } returns flowOf(emptyList()) every { settingsDataStore.chatSettings } returns flowOf(ChatSettings()) @@ -171,6 +148,52 @@ class ChatViewModelTest { assertEquals(listOf("m1"), vm.currentMessages().map { it.message.id }) } + @Test + fun initialHistoryLoad_isBounded() = runTest { + coEvery { api.getMessages("session-1", 100, null, "/test", null) } returns emptyList() + + createViewModel() + + coVerify(exactly = 1) { api.getMessages("session-1", 100, null, "/test", null) } + coVerify(exactly = 0) { api.getMessages("session-1", null, null, any(), null) } + } + + @Test + fun loadOlderMessages_increasesBoundAndPreservesChronologicalHistory() = runTest { + val newest = (101L..200L).map { assistantMessageDto("m$it", createdAt = it) } + val expanded = (1L..200L).map { assistantMessageDto("m$it", createdAt = it) } + coEvery { api.getMessages("session-1", 100, null, "/test", null) } returns newest + coEvery { api.getMessages("session-1", 200, null, "/test", null) } returns expanded + val vm = createViewModel() + + assertTrue(vm.uiState.value.hasOlderMessages) + vm.loadOlderMessages() + advanceUntilIdle() + + coVerify(exactly = 1) { api.getMessages("session-1", 200, null, "/test", null) } + assertEquals((1L..200L).map { "m$it" }, vm.currentMessages().map { it.message.id }) + assertTrue(vm.uiState.value.hasOlderMessages) + } + + @Test + fun loadOlderMessages_doesNotOverwriteMessageDeliveredDuringHistoryWindow() = runTest { + val newest = (101L..200L).map { assistantMessageDto("m$it", createdAt = it) } + val expanded = (1L..200L).map { assistantMessageDto("m$it", createdAt = it) } + coEvery { api.getMessages("session-1", 100, null, "/test", null) } returns newest + coEvery { api.getMessages("session-1", 200, null, "/test", null) } returns expanded + val vm = createViewModel() + sessionRepository.acceptEvent( + OpenCodeEvent.MessageUpdated(assistantMessage("m200", "session-1", createdAt = 999)) + ) + + vm.loadOlderMessages() + advanceUntilIdle() + + val raced = vm.currentMessages().filter { it.message.id == "m200" } + assertEquals(1, raced.size) + assertEquals(999, raced.single().message.createdAt) + } + @Test fun handleEvent_routesPermissionRequested_toDialogQueueManager() = runTest { val vm = createViewModel() @@ -321,15 +344,15 @@ class ChatViewModelTest { @Test fun sendMessage_undoSlashCommand_revertsToPreviousUserMessageBoundaryWithoutExecutingCommand() = runTest { - coEvery { api.getSession("session-1", any()) } returns sessionDto(revertMessageId = "user-2") - coEvery { api.getMessages("session-1", any(), any()) } returns listOf( + coEvery { api.getSession("session-1", any(), null) } returns sessionDto(revertMessageId = "user-2") + coEvery { api.getMessages("session-1", any(), null, any(), null) } returns listOf( userMessageDto("user-1", createdAt = 1), assistantMessageDto("assistant-1", createdAt = 2), userMessageDto("user-2", createdAt = 3), assistantMessageDto("assistant-2", createdAt = 4), ) - coEvery { api.revertSession(any(), any(), any()) } returns sessionDto(revertMessageId = "user-1") - coEvery { api.executeCommand(any(), any(), any()) } returns assistantMessageDto( + coEvery { api.revertSession(any(), any(), any(), null) } returns sessionDto(revertMessageId = "user-1") + coEvery { api.executeCommand(any(), any(), any(), null) } returns assistantMessageDto( "command-response", createdAt = 5 ) @@ -339,25 +362,25 @@ class ChatViewModelTest { vm.sendMessage() advanceUntilIdle() - coVerify(exactly = 0) { api.executeCommand(any(), any(), any()) } + coVerify(exactly = 0) { api.executeCommand(any(), any(), any(), null) } coVerify(exactly = 1) { - api.revertSession("session-1", RevertSessionRequest(messageID = "user-1"), "/test") + api.revertSession("session-1", RevertSessionRequest(messageID = "user-1"), "/test", null) } } @Test fun sendMessage_redoSlashCommandWithActiveRevert_revertsToNextUserMessageBoundaryWithoutExecutingCommand() = runTest { - coEvery { api.getSession("session-1", any()) } returns sessionDto(revertMessageId = "user-1") - coEvery { api.getMessages("session-1", any(), any()) } returns listOf( + coEvery { api.getSession("session-1", any(), null) } returns sessionDto(revertMessageId = "user-1") + coEvery { api.getMessages("session-1", any(), null, any(), null) } returns listOf( userMessageDto("user-1", createdAt = 1), assistantMessageDto("assistant-1", createdAt = 2), userMessageDto("user-2", createdAt = 3), assistantMessageDto("assistant-2", createdAt = 4), ) - coEvery { api.revertSession(any(), any(), any()) } returns sessionDto(revertMessageId = "user-2") - coEvery { api.unrevertSession(any(), any()) } returns sessionDto(revertMessageId = null) - coEvery { api.executeCommand(any(), any(), any()) } returns assistantMessageDto( + coEvery { api.revertSession(any(), any(), any(), null) } returns sessionDto(revertMessageId = "user-2") + coEvery { api.unrevertSession(any(), any(), null) } returns sessionDto(revertMessageId = null) + coEvery { api.executeCommand(any(), any(), any(), null) } returns assistantMessageDto( "command-response", createdAt = 5 ) @@ -367,17 +390,17 @@ class ChatViewModelTest { vm.sendMessage() advanceUntilIdle() - coVerify(exactly = 0) { api.executeCommand(any(), any(), any()) } + coVerify(exactly = 0) { api.executeCommand(any(), any(), any(), null) } coVerify(exactly = 1) { - api.revertSession("session-1", RevertSessionRequest(messageID = "user-2"), "/test") + api.revertSession("session-1", RevertSessionRequest(messageID = "user-2"), "/test", null) } - coVerify(exactly = 0) { api.unrevertSession(any(), any()) } + coVerify(exactly = 0) { api.unrevertSession(any(), any(), null) } } @Test fun sendMessage_clearsInput_andMarksBusyUntilSseStatus() = runTest { val vm = createViewModel() - coEvery { api.sendMessageAsync(any(), any(), any()) } returns Unit + coEvery { api.sendMessageAsync(any(), any(), any(), null) } returns Unit vm.updateInput("hello") vm.sendMessage() @@ -393,7 +416,7 @@ class ChatViewModelTest { fun sendMessage_restoresInput_onApiError() = runTest { val vm = createViewModel() - coEvery { api.sendMessageAsync(any(), any(), any()) } throws RuntimeException("boom") + coEvery { api.sendMessageAsync(any(), any(), any(), null) } throws RuntimeException("boom") vm.updateInput("hello") vm.sendMessage() @@ -401,15 +424,18 @@ class ChatViewModelTest { assertEquals("hello", vm.uiState.value.inputText) assertFalse(vm.uiState.value.isSending) - assertTrue(vm.uiState.value.error?.contains("boom") == true) + assertEquals( + "Could not send the message. Check the connection and try again.", + vm.uiState.value.error, + ) } @Test fun sendMessage_sendsBackendFileUrls_forWorkspaceAttachmentsWithSpecialCharacters() = runTest { val vm = createViewModel() val request = slot() - coEvery { api.sendMessageAsync(any(), capture(request), any()) } returns Unit - coEvery { api.listFiles("src/My File %/ümlaut/こんにちは", "/test") } returns listOf( + coEvery { api.sendMessageAsync(any(), capture(request), any(), null) } returns Unit + coEvery { api.listFiles("src/My File %/ümlaut/こんにちは", "/test", null) } returns listOf( FileNodeDto( name = "hash#query?.kt", path = "src/My File %/ümlaut/こんにちは/hash#query?.kt", @@ -430,7 +456,7 @@ class ChatViewModelTest { vm.sendMessage() advanceUntilIdle() - coVerify { api.sendMessageAsync("session-1", any(), "/test") } + coVerify { api.sendMessageAsync("session-1", any(), "/test", null) } assertEquals( "file:/test/src/My%20File%20%25/%C3%BCmlaut/%E3%81%93%E3%82%93%E3%81%AB%E3%81%A1%E3%81%AF/hash%23query%3F.kt", request.captured.parts.single().url, @@ -441,7 +467,7 @@ class ChatViewModelTest { fun abortSession_clearsStreamingFlags_andBusyState() = runTest { val vm = createViewModel() - coEvery { api.abortSession(any(), any()) } returns Response.success(Unit) + coEvery { api.abortSession(any(), any(), null) } returns Response.success(Unit) emitEvent(OpenCodeEvent.MessageUpdated(assistantMessage(id = "m1", sessionId = "session-1", createdAt = 1))) emitEvent( OpenCodeEvent.MessagePartUpdated( @@ -471,12 +497,12 @@ class ChatViewModelTest { fun abortSession_sanitizesUnexpectedJsonErrors() = runTest { val vm = createViewModel() - coEvery { api.abortSession(any(), any()) } throws RuntimeException("{\"error\":\"boom\"}") + coEvery { api.abortSession(any(), any(), null) } throws RuntimeException("{\"error\":\"boom\"}") vm.abortSession() flushMessages() - assertEquals("Failed to stop run: Unable to stop run", vm.uiState.value.error) + assertEquals("Could not stop the run. Try again.", vm.uiState.value.error) } @Test @@ -499,7 +525,7 @@ class ChatViewModelTest { @Test fun loadSession_notFound_emitsSessionMissing() = runTest { - coEvery { api.getSession("session-1", any()) } throws httpNotFound() + coEvery { api.getSession("session-1", any(), null) } throws httpNotFound() val vm = createViewModel() advanceUntilIdle() @@ -511,16 +537,16 @@ class ChatViewModelTest { @Test fun loadCommands_failureKeepsBuiltIns_andAllowsRetryForWorkspaceCommands() = runTest { val vm = createViewModel() - coEvery { api.listCommands(any()) } throws RuntimeException("network down") + coEvery { api.listCommands(any(), null) } throws RuntimeException("network down") vm.loadCommands() advanceUntilIdle() assertTrue(vm.uiState.value.commands.any { it.name == "help" }) assertFalse(vm.uiState.value.hasLoadedWorkspaceCommands) - assertEquals("network down", vm.uiState.value.commandLoadError) + assertEquals("Could not load workspace commands. Try again.", vm.uiState.value.commandLoadError) - coEvery { api.listCommands(any()) } returns listOf( + coEvery { api.listCommands(any(), null) } returns listOf( CommandDto(name = "workspace", description = "Workspace command") ) @@ -535,15 +561,15 @@ class ChatViewModelTest { @Test fun executeCommand_undoPaletteSelection_revertsToPreviousUserMessageBoundaryWithoutExecutingCommandEndpoint() = runTest { - coEvery { api.getSession("session-1", any()) } returns sessionDto(revertMessageId = "user-2") - coEvery { api.getMessages("session-1", any(), any()) } returns listOf( + coEvery { api.getSession("session-1", any(), null) } returns sessionDto(revertMessageId = "user-2") + coEvery { api.getMessages("session-1", any(), null, any(), null) } returns listOf( userMessageDto("user-1", createdAt = 1), assistantMessageDto("assistant-1", createdAt = 2), userMessageDto("user-2", createdAt = 3), assistantMessageDto("assistant-2", createdAt = 4), ) - coEvery { api.revertSession(any(), any(), any()) } returns sessionDto(revertMessageId = "user-1") - coEvery { api.executeCommand(any(), any(), any()) } returns assistantMessageDto( + coEvery { api.revertSession(any(), any(), any(), null) } returns sessionDto(revertMessageId = "user-1") + coEvery { api.executeCommand(any(), any(), any(), null) } returns assistantMessageDto( "command-response", createdAt = 5 ) @@ -552,25 +578,25 @@ class ChatViewModelTest { vm.executeCommand("undo", "") advanceUntilIdle() - coVerify(exactly = 0) { api.executeCommand(any(), any(), any()) } + coVerify(exactly = 0) { api.executeCommand(any(), any(), any(), null) } coVerify(exactly = 1) { - api.revertSession("session-1", RevertSessionRequest(messageID = "user-1"), "/test") + api.revertSession("session-1", RevertSessionRequest(messageID = "user-1"), "/test", null) } } @Test fun executeCommand_redoPaletteSelectionWithActiveRevert_usesRevertBoundaryNotCommandEndpoint() = runTest { - coEvery { api.getSession("session-1", any()) } returns sessionDto(revertMessageId = "user-1") - coEvery { api.getMessages("session-1", any(), any()) } returns listOf( + coEvery { api.getSession("session-1", any(), null) } returns sessionDto(revertMessageId = "user-1") + coEvery { api.getMessages("session-1", any(), null, any(), null) } returns listOf( userMessageDto("user-1", createdAt = 1), assistantMessageDto("assistant-1", createdAt = 2), userMessageDto("user-2", createdAt = 3), assistantMessageDto("assistant-2", createdAt = 4), ) - coEvery { api.revertSession(any(), any(), any()) } returns sessionDto(revertMessageId = "user-2") - coEvery { api.unrevertSession(any(), any()) } returns sessionDto(revertMessageId = null) - coEvery { api.executeCommand(any(), any(), any()) } returns assistantMessageDto( + coEvery { api.revertSession(any(), any(), any(), null) } returns sessionDto(revertMessageId = "user-2") + coEvery { api.unrevertSession(any(), any(), null) } returns sessionDto(revertMessageId = null) + coEvery { api.executeCommand(any(), any(), any(), null) } returns assistantMessageDto( "command-response", createdAt = 5 ) @@ -579,11 +605,11 @@ class ChatViewModelTest { vm.executeCommand("redo", "") advanceUntilIdle() - coVerify(exactly = 0) { api.executeCommand(any(), any(), any()) } + coVerify(exactly = 0) { api.executeCommand(any(), any(), any(), null) } coVerify(exactly = 1) { - api.revertSession("session-1", RevertSessionRequest(messageID = "user-2"), "/test") + api.revertSession("session-1", RevertSessionRequest(messageID = "user-2"), "/test", null) } - coVerify(exactly = 0) { api.unrevertSession(any(), any()) } + coVerify(exactly = 0) { api.unrevertSession(any(), any(), null) } } private fun TestScope.createViewModel( @@ -600,7 +626,6 @@ class ChatViewModelTest { workspaceClient = workspaceClient, sessionRepository = sessionRepository, uploadCoordinator = testUploadCoordinator(fileRepository), - connectionManager = connectionManager, settingsDataStore = settingsDataStore, hapticFeedback = hapticFeedback, ) diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/DialogQueueManagerTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/DialogQueueManagerTest.kt index 3a52b598..493aaada 100644 --- a/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/DialogQueueManagerTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/DialogQueueManagerTest.kt @@ -134,6 +134,60 @@ class DialogQueueManagerTest { assertEquals(json.encodeToString(question), handle.get(KEY_PENDING_QUESTION)) } + @Test + fun pendingQuestion_restoresExactly() { + val question = questionRequest(id = "restored") + val encoded = json.encodeToString(question) + val handle = SavedStateHandle(mapOf(KEY_PENDING_QUESTION to encoded)) + + val manager = manager(handle) + + assertEquals(question, manager.pendingQuestion.value) + assertEquals(encoded, handle.get(KEY_PENDING_QUESTION)) + } + + @Test + fun pendingQuestion_oversizedJsonRemovesPersistenceAndSmallerQuestionRecovers() = runTest { + val handle = SavedStateHandle() + val manager = manager(handle) + val oversized = questionRequest(id = "large", questionText = "x".repeat(70_000)) + + manager.setPendingQuestion(oversized) + advanceUntilIdle() + + assertEquals(oversized, manager.pendingQuestion.value) + assertNull(handle.get(KEY_PENDING_QUESTION)) + + val small = questionRequest(id = "small") + manager.setPendingQuestion(small) + advanceUntilIdle() + + assertEquals(json.encodeToString(small), handle.get(KEY_PENDING_QUESTION)) + } + + @Test + fun queuedQuestions_oversizedJsonRemovesPersistenceAndSmallerQueueRecovers() = runTest { + val handle = SavedStateHandle() + val manager = manager(handle) + val current = questionRequest(id = "current") + val oversized = questionRequest(id = "large", questionText = "x".repeat(70_000)) + + manager.enqueueQuestion(current) + manager.enqueueQuestion(oversized) + advanceUntilIdle() + + assertNull(handle.get(KEY_PENDING_QUESTIONS_QUEUE)) + + manager.clearQuestion() + manager.enqueueQuestion(questionRequest(id = "small")) + advanceUntilIdle() + + val persisted = json.decodeFromString>( + handle.get(KEY_PENDING_QUESTIONS_QUEUE)!! + ) + assertEquals(listOf("small"), persisted.map { it.id }) + } + @Test fun clearQuestion_advancesToNextInQueue() = runTest { val handle = SavedStateHandle() @@ -181,14 +235,14 @@ class DialogQueueManagerTest { assertNull(handle.get(KEY_PENDING_QUESTIONS_QUEUE)) } - private fun questionRequest(id: String): QuestionRequest { + private fun questionRequest(id: String, questionText: String = "Q?"): QuestionRequest { return QuestionRequest( id = id, sessionID = "session-1", questions = listOf( Question( header = "Header", - question = "Q?", + question = questionText, options = listOf(QuestionOption(label = "Yes", description = "")) ) ) diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/ModelAgentManagerTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/ModelAgentManagerTest.kt index 7a65187a..652427db 100644 --- a/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/ModelAgentManagerTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/ModelAgentManagerTest.kt @@ -2,7 +2,7 @@ package dev.blazelight.p4oc.ui.screens.chat import dev.blazelight.p4oc.core.datastore.SettingsDataStore import dev.blazelight.p4oc.core.log.AppLog -import dev.blazelight.p4oc.core.network.ConnectionManager +import dev.blazelight.p4oc.core.network.ConnectionState import dev.blazelight.p4oc.core.network.OpenCodeApi import dev.blazelight.p4oc.data.remote.dto.AgentDto import dev.blazelight.p4oc.data.remote.dto.ModelDto @@ -10,6 +10,11 @@ import dev.blazelight.p4oc.data.remote.dto.ModelInput import dev.blazelight.p4oc.data.remote.dto.ModelRefDto import dev.blazelight.p4oc.data.remote.dto.ProviderDto import dev.blazelight.p4oc.data.remote.dto.ProvidersResponseDto +import dev.blazelight.p4oc.data.server.ActiveServerApiProvider +import dev.blazelight.p4oc.data.workspace.WorkspaceClient +import dev.blazelight.p4oc.domain.server.ServerGeneration +import dev.blazelight.p4oc.domain.server.ServerRef +import dev.blazelight.p4oc.domain.workspace.Workspace import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -17,6 +22,7 @@ import io.mockk.mockk import io.mockk.mockkObject import io.mockk.unmockkObject import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent @@ -29,9 +35,14 @@ import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) class ModelAgentManagerTest { - private val connectionManager: ConnectionManager = mockk() private val settingsDataStore: SettingsDataStore = mockk(relaxed = true) private val api: OpenCodeApi = mockk() + private val workspaceClient = WorkspaceClient( + workspace = Workspace(ServerRef.fromEndpointKey("http://test.local"), "/test"), + generation = ServerGeneration(1L), + apiProvider = ActiveServerApiProvider { _, _ -> api }, + connectionState = MutableStateFlow(ConnectionState.Disconnected), + ) @Before fun setUp() { @@ -40,7 +51,6 @@ class ModelAgentManagerTest { every { AppLog.d(any(), any<() -> String>()) } returns Unit every { AppLog.e(any(), any()) } returns Unit every { AppLog.e(any(), any(), any()) } returns Unit - every { connectionManager.getApi() } returns api every { settingsDataStore.favoriteModels } returns flowOf(emptySet()) every { settingsDataStore.recentModels } returns flowOf(emptyList()) } @@ -68,23 +78,25 @@ class ModelAgentManagerTest { // ── loadAgents ────────────────────────────────────────────────────────── @Test - fun `loadAgents filters to primary non-hidden agents`() = runTest { + fun `loadAgents includes primary and all non-hidden agents`() = runTest { val agents = listOf( makeAgent("build", mode = "primary"), makeAgent("code", mode = "primary"), + makeAgent("general", mode = "all"), makeAgent("hidden-agent", mode = "primary", hidden = true), makeAgent("subagent", mode = "subagent") ) - coEvery { api.getAgents() } returns agents + coEvery { api.getAgents(any(), null) } returns agents - val manager = ModelAgentManager(connectionManager, settingsDataStore, this) + val manager = ModelAgentManager(workspaceClient, settingsDataStore, this) manager.loadAgents() advanceUntilIdle() val result = manager.availableAgents.value - assertEquals(2, result.size) + assertEquals(3, result.size) assertEquals("build", result[0].name) assertEquals("code", result[1].name) + assertEquals("general", result[2].name) } @Test @@ -94,9 +106,9 @@ class ModelAgentManagerTest { makeAgent("build", mode = "primary"), makeAgent("ask", mode = "primary") ) - coEvery { api.getAgents() } returns agents + coEvery { api.getAgents(any(), null) } returns agents - val manager = ModelAgentManager(connectionManager, settingsDataStore, this) + val manager = ModelAgentManager(workspaceClient, settingsDataStore, this) manager.loadAgents() advanceUntilIdle() @@ -109,9 +121,9 @@ class ModelAgentManagerTest { makeAgent("code", mode = "primary"), makeAgent("ask", mode = "primary") ) - coEvery { api.getAgents() } returns agents + coEvery { api.getAgents(any(), null) } returns agents - val manager = ModelAgentManager(connectionManager, settingsDataStore, this) + val manager = ModelAgentManager(workspaceClient, settingsDataStore, this) manager.loadAgents() advanceUntilIdle() @@ -127,9 +139,9 @@ class ModelAgentManagerTest { model = ModelRefDto(providerID = "anthropic", modelID = "claude-3") ) ) - coEvery { api.getAgents() } returns agents + coEvery { api.getAgents(any(), null) } returns agents - val manager = ModelAgentManager(connectionManager, settingsDataStore, this) + val manager = ModelAgentManager(workspaceClient, settingsDataStore, this) manager.loadAgents() advanceUntilIdle() @@ -150,9 +162,9 @@ class ModelAgentManagerTest { model = ModelRefDto(providerID = "anthropic", modelID = "claude-3") ) ) - coEvery { api.getAgents() } returns agents + coEvery { api.getAgents(any(), null) } returns agents - val manager = ModelAgentManager(connectionManager, settingsDataStore, this) + val manager = ModelAgentManager(workspaceClient, settingsDataStore, this) manager.loadAgents() advanceUntilIdle() @@ -184,10 +196,10 @@ class ModelAgentManagerTest { default = mapOf("openai" to "gpt-4"), connected = listOf("openai") ) - coEvery { api.getAgents() } returns agents - coEvery { api.getProviders() } returns providersResponse + coEvery { api.getAgents(any(), null) } returns agents + coEvery { api.getProviders(any(), null) } returns providersResponse - val manager = ModelAgentManager(connectionManager, settingsDataStore, this) + val manager = ModelAgentManager(workspaceClient, settingsDataStore, this) manager.loadAgents() advanceUntilIdle() manager.loadModels() @@ -196,23 +208,11 @@ class ModelAgentManagerTest { assertEquals(ModelInput(providerID = "anthropic", modelID = "claude-3"), manager.selectedModel.value) } - @Test - fun `loadAgents handles null API gracefully`() = runTest { - every { connectionManager.getApi() } returns null - - val manager = ModelAgentManager(connectionManager, settingsDataStore, this) - manager.loadAgents() - advanceUntilIdle() - - assertTrue(manager.availableAgents.value.isEmpty()) - assertNull(manager.selectedAgent.value) - } - @Test fun `loadAgents handles API error gracefully`() = runTest { - coEvery { api.getAgents() } throws RuntimeException("Network error") + coEvery { api.getAgents(any(), null) } throws RuntimeException("Network error") - val manager = ModelAgentManager(connectionManager, settingsDataStore, this) + val manager = ModelAgentManager(workspaceClient, settingsDataStore, this) manager.loadAgents() advanceUntilIdle() @@ -241,9 +241,9 @@ class ModelAgentManagerTest { default = mapOf("openai" to "gpt-4"), connected = listOf("anthropic") ) - coEvery { api.getProviders() } returns providersResponse + coEvery { api.getProviders(any(), null) } returns providersResponse - val manager = ModelAgentManager(connectionManager, settingsDataStore, this) + val manager = ModelAgentManager(workspaceClient, settingsDataStore, this) advanceUntilIdle() manager.loadModels() @@ -282,9 +282,9 @@ class ModelAgentManagerTest { default = mapOf("openai" to "gpt-4"), connected = listOf("anthropic", "openai") ) - coEvery { api.getProviders() } returns providersResponse + coEvery { api.getProviders(any(), null) } returns providersResponse - val manager = ModelAgentManager(connectionManager, settingsDataStore, this) + val manager = ModelAgentManager(workspaceClient, settingsDataStore, this) advanceUntilIdle() manager.loadModels() @@ -318,9 +318,9 @@ class ModelAgentManagerTest { default = mapOf("openai" to "gpt-4"), connected = listOf("anthropic", "openai") ) - coEvery { api.getProviders() } returns providersResponse + coEvery { api.getProviders(any(), null) } returns providersResponse - val manager = ModelAgentManager(connectionManager, settingsDataStore, this) + val manager = ModelAgentManager(workspaceClient, settingsDataStore, this) manager.selectModel(selectedModel) advanceUntilIdle() @@ -347,9 +347,9 @@ class ModelAgentManagerTest { default = mapOf("openai" to "gpt-4"), connected = listOf("openai") ) - coEvery { api.getProviders() } returns providersResponse + coEvery { api.getProviders(any(), null) } returns providersResponse - val manager = ModelAgentManager(connectionManager, settingsDataStore, this) + val manager = ModelAgentManager(workspaceClient, settingsDataStore, this) manager.selectModel(staleModel) advanceUntilIdle() @@ -387,9 +387,9 @@ class ModelAgentManagerTest { default = mapOf("anthropic" to "claude-3"), connected = listOf("anthropic") ) - coEvery { api.getProviders() } returns providersResponse + coEvery { api.getProviders(any(), null) } returns providersResponse - val manager = ModelAgentManager(connectionManager, settingsDataStore, this) + val manager = ModelAgentManager(workspaceClient, settingsDataStore, this) advanceUntilIdle() manager.loadModels() @@ -421,9 +421,9 @@ class ModelAgentManagerTest { default = mapOf("openai" to "gpt-4"), connected = listOf("openai") ) - coEvery { api.getProviders() } returns providersResponse + coEvery { api.getProviders(any(), null) } returns providersResponse - val manager = ModelAgentManager(connectionManager, settingsDataStore, this) + val manager = ModelAgentManager(workspaceClient, settingsDataStore, this) advanceUntilIdle() manager.loadModels() @@ -438,7 +438,7 @@ class ModelAgentManagerTest { @Test fun `active model change updates selection when no agent or explicit model override`() = runTest { val coordinator = ModelSelectionCoordinator() - val manager = ModelAgentManager(connectionManager, settingsDataStore, backgroundScope, null, coordinator) + val manager = ModelAgentManager(workspaceClient, settingsDataStore, backgroundScope, null, coordinator) advanceUntilIdle() runCurrent() @@ -452,7 +452,7 @@ class ModelAgentManagerTest { @Test fun `active model change does not replace explicit user selection`() = runTest { val coordinator = ModelSelectionCoordinator() - val manager = ModelAgentManager(connectionManager, settingsDataStore, backgroundScope, null, coordinator) + val manager = ModelAgentManager(workspaceClient, settingsDataStore, backgroundScope, null, coordinator) val explicitModel = ModelInput(providerID = "openai", modelID = "gpt-4") manager.selectModel(explicitModel) advanceUntilIdle() @@ -474,8 +474,8 @@ class ModelAgentManagerTest { model = ModelRefDto(providerID = "openai", modelID = "gpt-4") ) ) - coEvery { api.getAgents() } returns agents - val manager = ModelAgentManager(connectionManager, settingsDataStore, backgroundScope, null, coordinator) + coEvery { api.getAgents(any(), null) } returns agents + val manager = ModelAgentManager(workspaceClient, settingsDataStore, backgroundScope, null, coordinator) manager.loadAgents() advanceUntilIdle() runCurrent() @@ -492,7 +492,7 @@ class ModelAgentManagerTest { fun `selectModel adds to recent models`() = runTest { val model = ModelInput(providerID = "anthropic", modelID = "claude-3") - val manager = ModelAgentManager(connectionManager, settingsDataStore, this) + val manager = ModelAgentManager(workspaceClient, settingsDataStore, this) manager.selectModel(model) advanceUntilIdle() diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/PendingPermissionAttentionVersionTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/PendingPermissionAttentionVersionTest.kt index 9687c187..e0c63f2b 100644 --- a/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/PendingPermissionAttentionVersionTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/ui/screens/chat/PendingPermissionAttentionVersionTest.kt @@ -1,7 +1,18 @@ +@file:Suppress("ImportOrdering") + package dev.blazelight.p4oc.ui.screens.chat +import dev.blazelight.p4oc.domain.model.Message +import dev.blazelight.p4oc.domain.model.MessageWithParts +import dev.blazelight.p4oc.domain.model.Part +import dev.blazelight.p4oc.domain.model.Permission +import dev.blazelight.p4oc.domain.model.ToolState +import dev.blazelight.p4oc.domain.model.TokenUsage +import kotlinx.serialization.json.buildJsonObject import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Test class PendingPermissionAttentionVersionTest { @@ -33,4 +44,147 @@ class PendingPermissionAttentionVersionTest { assertEquals("Same permission set must produce same version regardless of order", a, b) } + + @Test + fun `new permission requests attention even when older permission remains pending`() { + assertTrue(hasNewPendingPermission(setOf("call_abc"), setOf("call_abc", "call_def"))) + } + + @Test + fun `resolution does not request another reveal`() { + assertFalse(hasNewPendingPermission(setOf("call_abc", "call_def"), setOf("call_def"))) + } + + @Test + fun `permission reveal targets its existing message block`() { + val blocks = groupMessagesIntoBlocks( + listOf( + assistantMessage("message-1", "call_old"), + assistantMessage("message-2", "call_pending"), + ) + ) + + assertEquals(0, pendingPermissionBlockIndex(blocks, setOf("call_pending"))) + } + + @Test + fun `callID-less permission is rendered as session pending`() { + val permission = permission(id = "permission-1", callId = null) + + assertEquals( + listOf(permission), + unmatchedPendingPermissions( + messages = listOf(assistantMessage("message-1", "call-1")), + pendingPermissionsByKey = mapOf("permission:permission-1" to permission), + ), + ) + } + + @Test + fun `tool-bound permission is not duplicated as session pending`() { + val permission = permission(id = "permission-1", callId = "call-1") + + assertTrue( + unmatchedPendingPermissions( + messages = listOf(assistantMessage("message-1", "call-1")), + pendingPermissionsByKey = mapOf("call-1" to permission), + ).isEmpty(), + ) + } + + @Test + fun `permission with missing tool is rendered as session pending`() { + val permission = permission(id = "permission-1", callId = "call-missing") + + assertEquals( + listOf(permission), + unmatchedPendingPermissions( + messages = listOf(assistantMessage("message-1", "call-other")), + pendingPermissionsByKey = mapOf("call-missing" to permission), + ), + ) + } + + @Test + fun `empty history with pending question is content`() { + assertTrue( + hasChatContent( + hasMessages = false, + isBusy = false, + hasPendingQuestion = true, + hasSessionPendingPermissions = false, + ), + ) + } + + @Test + fun `empty history with unmatched permission is content`() { + val sessionPermissions = unmatchedPendingPermissions( + messages = emptyList(), + pendingPermissionsByKey = mapOf( + "permission:permission-1" to permission(id = "permission-1", callId = null), + ), + ) + + assertTrue( + hasChatContent( + hasMessages = false, + isBusy = false, + hasPendingQuestion = false, + hasSessionPendingPermissions = sessionPermissions.isNotEmpty(), + ), + ) + } + + @Test + fun `truly empty idle session has no content`() { + assertFalse( + hasChatContent( + hasMessages = false, + isBusy = false, + hasPendingQuestion = false, + hasSessionPendingPermissions = false, + ), + ) + } + + private fun permission(id: String, callId: String?) = Permission( + id = id, + type = "bash", + patterns = listOf("pwd"), + sessionID = "session-1", + messageID = "", + callID = callId, + metadata = buildJsonObject {}, + always = emptyList(), + ) + + private fun assistantMessage(messageId: String, callId: String) = MessageWithParts( + message = Message.Assistant( + id = messageId, + sessionID = "session-1", + parentID = "parent-1", + createdAt = 1L, + modelID = "model-1", + providerID = "provider-1", + mode = "agent", + agent = "build", + path = null, + cost = 0.0, + tokens = TokenUsage(input = 0, output = 0), + completedAt = null, + error = null, + summary = null, + ), + parts = listOf( + Part.Tool( + id = "part-$callId", + sessionID = "session-1", + messageID = messageId, + callID = callId, + toolName = "bash", + state = ToolState.Pending(buildJsonObject {}, ""), + ) + ), + ) } diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/screens/files/FilesViewModelEditTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/screens/files/FilesViewModelEditTest.kt index 4d093f29..0f480c6d 100644 --- a/app/src/test/java/dev/blazelight/p4oc/ui/screens/files/FilesViewModelEditTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/ui/screens/files/FilesViewModelEditTest.kt @@ -135,6 +135,38 @@ class FilesViewModelEditTest { assertNull(vm.editState.value.pendingSavePreview) } + @Test + fun readOnlyCapability_blocksSavePreviewAndWrite() = runTest { + val repo = FakeRepo(content = "v1", canWrite = false) + val vm = FilesViewModel(repo, testUploadCoordinator(repo)) + vm.loadFileContent("p") + vm.onEditorTextChange("v2") + + vm.requestSave() + vm.confirmSave() + vm.overwriteAnyway() + + assertTrue(vm.uiState.value.capabilitiesLoaded) + assertFalse(vm.uiState.value.capabilities.canWrite) + assertNull(vm.editState.value.pendingSavePreview) + assertTrue(repo.writes.isEmpty()) + } + + @Test + fun writableCapability_preservesSaveFlow() = runTest { + val repo = FakeRepo(content = "v1", canWrite = true) + val vm = FilesViewModel(repo, testUploadCoordinator(repo)) + vm.loadFileContent("p") + vm.onEditorTextChange("v2") + + vm.requestSave() + assertNotNull(vm.editState.value.pendingSavePreview) + vm.confirmSave() + + assertEquals(1, repo.writes.size) + assertEquals("v2", repo.writes.single().content) + } + @Test fun recreateWithSameSavedStateHandle_restoresDirtyEditBuffer() = runTest { val savedStateHandle = SavedStateHandle() @@ -154,6 +186,78 @@ class FilesViewModelEditTest { assertTrue(edit.isDirty) } + @Test + fun editSnapshot_atCombinedCharacterCeilingIsPersisted() = runTest { + val savedStateHandle = SavedStateHandle() + val original = "o" + val current = "c".repeat(MAX_SAVED_EDIT_CONTENT_CHARS - original.length) + val repo = FakeRepo(content = original, hash = "hash-at-limit") + val vm = FilesViewModel(repo, testUploadCoordinator(repo), savedStateHandle) + vm.loadFileContent("at-limit.txt") + + vm.onEditorTextChange(current) + + val restored = FilesViewModel(repo, testUploadCoordinator(repo), savedStateHandle).editState.value + assertEquals("at-limit.txt", restored.path) + assertEquals(original, restored.originalContent) + assertEquals(current, restored.currentContent) + assertEquals("hash-at-limit", restored.baselineHash) + } + + @Test + fun editSnapshot_overCombinedCharacterCeilingRemovesEveryPersistedEditKey() = runTest { + val savedStateHandle = SavedStateHandle() + val repo = FakeRepo(content = "o", hash = "oversized-hash") + val vm = FilesViewModel(repo, testUploadCoordinator(repo), savedStateHandle) + vm.loadFileContent("oversized.txt") + + vm.onEditorTextChange("c".repeat(MAX_SAVED_EDIT_CONTENT_CHARS)) + + assertFalse(savedStateHandle.contains("files_edit_path")) + assertFalse(savedStateHandle.contains("files_edit_original_content")) + assertFalse(savedStateHandle.contains("files_edit_current_content")) + assertFalse(savedStateHandle.contains("files_edit_baseline_hash")) + assertEquals("oversized.txt", vm.editState.value.path) + assertEquals(MAX_SAVED_EDIT_CONTENT_CHARS, vm.editState.value.currentContent.length) + } + + @Test + fun editSnapshot_shrinkingAfterOversizePersistsCompleteSnapshotAgain() = runTest { + val savedStateHandle = SavedStateHandle() + val repo = FakeRepo(content = "original", hash = "hash-after-shrink") + val vm = FilesViewModel(repo, testUploadCoordinator(repo), savedStateHandle) + vm.loadFileContent("shrunk.txt") + vm.onEditorTextChange("x".repeat(MAX_SAVED_EDIT_CONTENT_CHARS)) + + vm.onEditorTextChange("small again") + + val restored = FilesViewModel(repo, testUploadCoordinator(repo), savedStateHandle).editState.value + assertEquals("shrunk.txt", restored.path) + assertEquals("original", restored.originalContent) + assertEquals("small again", restored.currentContent) + assertEquals("hash-after-shrink", restored.baselineHash) + assertTrue(restored.isDirty) + } + + @Test + fun editSnapshot_smallContentRestoresExactly() = runTest { + val savedStateHandle = SavedStateHandle() + val original = "alpha\u0000\uD83D\uDE80\nline two" + val current = "alpha\u0000\uD83D\uDE80\nline two edited" + val repo = FakeRepo(content = original, hash = "exact-hash") + val vm = FilesViewModel(repo, testUploadCoordinator(repo), savedStateHandle) + vm.loadFileContent("exact.txt") + vm.onEditorTextChange(current) + + val restored = FilesViewModel(repo, testUploadCoordinator(repo), savedStateHandle).editState.value + + assertEquals("exact.txt", restored.path) + assertEquals(original, restored.originalContent) + assertEquals(current, restored.currentContent) + assertEquals("exact-hash", restored.baselineHash) + assertTrue(restored.isDirty) + } + @Test fun loadFileContent_preservesRestoredDirtyBufferForSamePath() = runTest { val savedStateHandle = SavedStateHandle() @@ -196,6 +300,33 @@ class FilesViewModelEditTest { assertEquals("src", recreated.uiState.value.currentPath) } + @Test + fun explorerQueriesAreBoundedBeforeStateAndPersistence() = runTest { + val savedStateHandle = SavedStateHandle() + val repo = FakeRepo(content = "") + val viewModel = FilesViewModel(repo, testUploadCoordinator(repo), savedStateHandle) + val oversized = "x".repeat(2_000) + + viewModel.updateSearchQuery(oversized) + viewModel.updateSymbolQuery(oversized) + + assertEquals(1_024, viewModel.uiState.value.searchQuery.length) + assertEquals(1_024, viewModel.uiState.value.symbolQuery.length) + assertEquals(1_024, savedStateHandle.get(FilesViewModel.KEY_SEARCH_QUERY)?.length) + assertEquals(1_024, savedStateHandle.get(FilesViewModel.KEY_SYMBOL_QUERY)?.length) + } + + @Test + fun persistedNavigationHistoryIsBounded() = runTest { + val savedStateHandle = SavedStateHandle() + val repo = FakeRepo(content = "") + val viewModel = FilesViewModel(repo, testUploadCoordinator(repo), savedStateHandle) + + repeat(200) { index -> viewModel.navigateTo("path-$index") } + + assertEquals(128, savedStateHandle.get>(FilesViewModel.KEY_PATH_STACK)?.size) + } + @Test fun missingRestoredPathFallsBackToRootWithRestoreError() = runTest { val savedStateHandle = SavedStateHandle( @@ -212,10 +343,22 @@ class FilesViewModelEditTest { assertEquals("missing path", vm.uiState.value.pathRestoreError) } + @Test + fun initialRootLoadFailureIsExposedInsteadOfLookingEmpty() = runTest { + val repo = FakeRepo(content = "", failedPaths = setOf("")) + + val vm = FilesViewModel(repo, testUploadCoordinator(repo)) + + assertFalse(vm.uiState.value.isLoading) + assertEquals("missing path", vm.uiState.value.error) + assertTrue(vm.uiState.value.files.isEmpty()) + } + private class FakeRepo( val content: String, val hash: String? = null, val failedPaths: Set = emptySet(), + val canWrite: Boolean = true, val writeResult: FileOperationResult = FileOperationResult.Ok(FileWriteResult("p", hash = null)), ) : FileRepository { @@ -254,7 +397,7 @@ class FilesViewModelEditTest { override suspend fun uploadFile(request: FileUploadRequest): FileOperationResult = FileOperationResult.Ok(FileUploadResult(request.path)) - override suspend fun capabilities(): FileCapabilities = FileCapabilities(canWrite = true) + override suspend fun capabilities(): FileCapabilities = FileCapabilities(canWrite = canWrite) } private fun testUploadCoordinator(repo: FileRepository) = UploadCoordinator( diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/screens/files/upload/UploadOrchestratorTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/screens/files/upload/UploadOrchestratorTest.kt index 8ac17cd6..034a1700 100644 --- a/app/src/test/java/dev/blazelight/p4oc/ui/screens/files/upload/UploadOrchestratorTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/ui/screens/files/upload/UploadOrchestratorTest.kt @@ -8,14 +8,20 @@ import dev.blazelight.p4oc.data.files.FileUploadRequest import dev.blazelight.p4oc.data.files.FileUploadResult import dev.blazelight.p4oc.data.files.FileWriteRequest import dev.blazelight.p4oc.data.files.FileWriteResult +import dev.blazelight.p4oc.data.files.ofish.MAX_UPLOAD_SOURCE_BYTES +import dev.blazelight.p4oc.data.files.ofish.UPLOAD_TOO_LARGE_MESSAGE import dev.blazelight.p4oc.domain.model.FileContent import dev.blazelight.p4oc.domain.model.Symbol import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test import java.io.ByteArrayInputStream @@ -148,6 +154,23 @@ class UploadOrchestratorTest { assertEquals(1, state.successes.size) } + @Test + fun `known file above source byte ceiling fails before repository mutation`() = runTest { + val source = FakeUploadSource(emptyMap()) + val repo = FakeFileRepository(uploadOutcomes = mutableListOf()) + val orchestrator = UploadOrchestrator(repo, source, retryDelayMillis = { 0L }) + + val state = orchestrator.run( + "", + listOf(plan("oversize-source", "large.bin", size = MAX_UPLOAD_SOURCE_BYTES + 1)), + ) + + assertEquals(0, repo.uploadCalls.size) + val phase = state.items.single().phase + assertTrue(phase is UploadPhase.Failed) + assertEquals(UPLOAD_TOO_LARGE_MESSAGE, (phase as UploadPhase.Failed).message) + } + @Test fun `retryFailed reruns only failed items and keeps successes`() = runTest { val source = FakeUploadSource(mapOf("a" to byteArrayOf(1), "b" to byteArrayOf(2))) @@ -207,6 +230,60 @@ class UploadOrchestratorTest { assertTrue(orchestrator.state.value.cancelled) } + @Test + fun `in-flight completion cannot overwrite cancelled state`() = runTest { + val source = FakeUploadSource(mapOf("a" to byteArrayOf(1))) + val repo = GatedFileRepository() + val orchestrator = UploadOrchestrator(repo, source, retryDelayMillis = { 0L }) + val runJob = launch { orchestrator.run("", listOf(plan("a"))) } + repo.started.await() + + orchestrator.markCancelled() + repo.release.complete(Unit) + runJob.join() + + val state = orchestrator.state.value + assertTrue(state.cancelled) + assertFalse(state.isActive) + val phase = state.items.single().phase + assertTrue("expected Failed after late success, got $phase", phase is UploadPhase.Failed) + assertEquals("cancelled", (phase as UploadPhase.Failed).message) + } + + @Test + fun `coordinator cancel remains cancelled after worker completion`() = runTest { + val repo = GatedFileRepository() + val coordinator = UploadCoordinator(this, repositoryFactory = { repo }) + coordinator.upload(FakeUploadSource(mapOf("a" to byteArrayOf(1))), listOf("a"), "") + repo.started.await() + + coordinator.cancel() + repo.release.complete(Unit) + repo.completed.await() + advanceUntilIdle() + + val state = coordinator.state.value + assertTrue(state.cancelled) + assertFalse(state.isActive) + assertTrue(state.items.single().phase is UploadPhase.Failed) + } + + @Test + fun `coordinator dismiss remains empty after cancelled worker completion`() = runTest { + val repo = GatedFileRepository() + val coordinator = UploadCoordinator(this, repositoryFactory = { repo }) + coordinator.upload(FakeUploadSource(mapOf("a" to byteArrayOf(1))), listOf("a"), "") + repo.started.await() + + coordinator.cancel() + coordinator.dismiss() + repo.release.complete(Unit) + repo.completed.await() + advanceUntilIdle() + + assertEquals(UploadQueueState(), coordinator.state.value) + } + @Test fun `destination joins current path with sanitized name`() = runTest { val source = FakeUploadSource(mapOf("u" to byteArrayOf(1))) @@ -279,3 +356,29 @@ private class FakeFileRepository( } override suspend fun capabilities() = FileCapabilities(canUpload = true) } + +private class GatedFileRepository : FileRepository { + val started = CompletableDeferred() + val release = CompletableDeferred() + val completed = CompletableDeferred() + + override suspend fun uploadFile(request: FileUploadRequest): FileOperationResult { + started.complete(Unit) + return withContext(NonCancellable) { + release.await() + completed.complete(Unit) + FileOperationResult.Ok(FileUploadResult(path = request.path, hash = null)) + } + } + + override suspend fun listFiles(path: String) = FileOperationResult.Ok(FileList(path, emptyList())) + override suspend fun readFile(path: String) = + FileOperationResult.Ok(FileContent(type = "text", content = "", diff = null, mimeType = null)) + override suspend fun searchSymbols(query: String) = FileOperationResult.Ok>(emptyList()) + override suspend fun writeFile(request: FileWriteRequest) = + FileOperationResult.Ok(FileWriteResult(path = request.path, hash = null)) + override suspend fun createDirectory(path: String) = FileOperationResult.Ok(Unit) + override suspend fun renameFile(fromPath: String, toPath: String) = FileOperationResult.Ok(Unit) + override suspend fun deleteFile(path: String) = FileOperationResult.Ok(Unit) + override suspend fun capabilities() = FileCapabilities(canUpload = true) +} diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/screens/home/HomeSummaryBuilderTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/screens/home/HomeSummaryBuilderTest.kt index bc2e21c8..0caaa370 100644 --- a/app/src/test/java/dev/blazelight/p4oc/ui/screens/home/HomeSummaryBuilderTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/ui/screens/home/HomeSummaryBuilderTest.kt @@ -241,7 +241,7 @@ class HomeSummaryBuilderTest { "network unavailable", summary.servers.single { it.serverRef.endpointKey == betaRef.endpointKey }.failure, ) - assertEquals(listOf("Beta: network unavailable"), summary.partialFailures) + assertEquals(listOf("Beta: session data unavailable"), summary.partialFailures) assertEquals(setOf("loading", "cached"), summary.sessions.map { it.sessionId.value }.toSet()) assertEquals(2, summary.workspaces.size) } diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/screens/projects/ProjectsViewModelTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/screens/projects/ProjectsViewModelTest.kt new file mode 100644 index 00000000..8751bb03 --- /dev/null +++ b/app/src/test/java/dev/blazelight/p4oc/ui/screens/projects/ProjectsViewModelTest.kt @@ -0,0 +1,166 @@ +package dev.blazelight.p4oc.ui.screens.projects + +import dev.blazelight.p4oc.core.network.ConnectionState +import dev.blazelight.p4oc.core.network.OpenCodeApi +import dev.blazelight.p4oc.core.network.ServerConnectionRegistry +import dev.blazelight.p4oc.data.remote.dto.ProjectDto +import dev.blazelight.p4oc.data.remote.dto.ProjectTimeDto +import dev.blazelight.p4oc.data.server.ActiveServerApiProvider +import dev.blazelight.p4oc.data.workspace.WorkspaceClient +import dev.blazelight.p4oc.domain.model.OpenCodeEvent +import dev.blazelight.p4oc.domain.server.ScopedEvent +import dev.blazelight.p4oc.domain.server.ServerGeneration +import dev.blazelight.p4oc.domain.server.ServerRef +import dev.blazelight.p4oc.domain.server.WorkspaceKey +import dev.blazelight.p4oc.domain.workspace.Workspace +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ProjectsViewModelTest { + private val dispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun twoWorkspaceClients_callOnlyTheirExactServerApis() = runTest(dispatcher) { + val firstApi = mockk() + val secondApi = mockk() + val firstProject = project("first", "/first", created = 1) + val secondProject = project("second", "/second", created = 2) + coEvery { firstApi.listProjects(null, null) } returns listOf(firstProject) + coEvery { secondApi.listProjects(null, null) } returns listOf(secondProject) + coEvery { firstApi.listFiles(".", "/first", null) } returns emptyList() + coEvery { secondApi.listFiles(".", "/second", null) } returns emptyList() + + val first = ProjectsViewModel(client("https://first.test", 1, firstApi), emptyRegistry()) + val second = ProjectsViewModel(client("https://second.test", 2, secondApi), emptyRegistry()) + advanceUntilIdle() + + assertEquals(listOf(firstProject), first.uiState.value.projects) + assertEquals(listOf(secondProject), second.uiState.value.projects) + coVerify(exactly = 1) { firstApi.listProjects(null, null) } + coVerify(exactly = 1) { firstApi.listFiles(".", "/first", null) } + coVerify(exactly = 1) { secondApi.listProjects(null, null) } + coVerify(exactly = 1) { secondApi.listFiles(".", "/second", null) } + coVerify(exactly = 0) { firstApi.listFiles(".", "/second", null) } + coVerify(exactly = 0) { secondApi.listFiles(".", "/first", null) } + } + + @Test + fun loadProjects_sortsAndFiltersUsingReturnedProjectDirectories() = runTest(dispatcher) { + val api = mockk() + val older = project("older", "/older", created = 1) + val newer = project("newer", "/newer", created = 2) + coEvery { api.listProjects(null, null) } returns listOf(older, newer) + coEvery { api.listFiles(".", "/older", null) } throws IllegalStateException("missing") + coEvery { api.listFiles(".", "/newer", null) } returns emptyList() + + val viewModel = ProjectsViewModel(client("https://server.test", 1, api), emptyRegistry()) + advanceUntilIdle() + + assertEquals(listOf(newer), viewModel.uiState.value.projects) + assertEquals(1, viewModel.uiState.value.staleProjectCount) + assertNull(viewModel.uiState.value.error) + } + + @Test + fun projectEvents_refreshOnlyExactOwnerAndCoalesceBurst() = runTest(dispatcher) { + val api = mockk() + val initial = project("initial", "/initial", created = 1) + val refreshed = project("refreshed", "/refreshed", created = 2) + coEvery { api.listProjects(null, null) } returnsMany listOf(listOf(initial), listOf(refreshed)) + coEvery { api.listFiles(".", any(), null) } returns emptyList() + val client = client("https://server.test", 7, api) + val events = MutableSharedFlow(extraBufferCapacity = 8) + val registry = mockk() + every { registry.events(client.workspace.server) } returns events + + val viewModel = ProjectsViewModel(client, registry) + advanceUntilIdle() + + events.emit(scoped(client, ServerGeneration(8), OpenCodeEvent.ProjectDirectoriesUpdated("wrong-generation"))) + events.emit( + ScopedEvent( + serverRef = ServerRef.fromEndpointKey("https://other.test"), + generation = client.generation, + workspaceKey = client.workspace.key, + event = OpenCodeEvent.ProjectDirectoriesUpdated("wrong-server"), + ) + ) + events.emit( + ScopedEvent( + serverRef = client.workspace.server, + generation = client.generation, + workspaceKey = WorkspaceKey.Directory("/other"), + event = OpenCodeEvent.ProjectDirectoriesUpdated("wrong-workspace"), + ) + ) + advanceTimeBy(200) + coVerify(exactly = 1) { api.listProjects(null, null) } + + events.emit(scoped(client, client.generation, OpenCodeEvent.ProjectDirectoriesUpdated("initial"))) + events.emit(scoped(client, client.generation, OpenCodeEvent.ProjectDirectoriesUpdated("initial"))) + advanceTimeBy(151) + advanceUntilIdle() + + assertEquals(listOf(refreshed), viewModel.uiState.value.projects) + coVerify(exactly = 2) { api.listProjects(null, null) } + } + + private fun scoped( + client: WorkspaceClient, + generation: ServerGeneration, + event: OpenCodeEvent, + ) = ScopedEvent(client.workspace.server, generation, client.workspace.key, event) + + private fun emptyRegistry(): ServerConnectionRegistry = mockk { + every { events(any()) } returns emptyFlow() + } + + private fun client(endpoint: String, generation: Long, api: OpenCodeApi): WorkspaceClient { + val server = ServerRef.fromEndpointKey(endpoint) + return WorkspaceClient( + workspace = Workspace(server, directory = null), + generation = ServerGeneration(generation), + apiProvider = ActiveServerApiProvider { requestedServer, requestedGeneration -> + check(requestedServer == server) + check(requestedGeneration == ServerGeneration(generation)) + api + }, + connectionState = MutableStateFlow(ConnectionState.Disconnected), + ) + } + + private fun project(id: String, worktree: String, created: Long) = ProjectDto( + id = id, + worktree = worktree, + time = ProjectTimeDto(created = created), + ) +} diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/screens/server/ServerViewModelIssue31Test.kt b/app/src/test/java/dev/blazelight/p4oc/ui/screens/server/ServerViewModelIssue31Test.kt index 9e08d95c..81ac5e6c 100644 --- a/app/src/test/java/dev/blazelight/p4oc/ui/screens/server/ServerViewModelIssue31Test.kt +++ b/app/src/test/java/dev/blazelight/p4oc/ui/screens/server/ServerViewModelIssue31Test.kt @@ -1,11 +1,13 @@ +@file:Suppress("ImportOrdering") + package dev.blazelight.p4oc.ui.screens.server import dev.blazelight.p4oc.core.datastore.RecentServer import dev.blazelight.p4oc.core.datastore.SavedServer import dev.blazelight.p4oc.core.datastore.SettingsDataStore -import dev.blazelight.p4oc.core.network.ConnectionManager import dev.blazelight.p4oc.core.network.DiscoveryState import dev.blazelight.p4oc.core.network.MdnsDiscoveryManager +import dev.blazelight.p4oc.core.network.ServerConnectionRegistry import dev.blazelight.p4oc.core.network.ServerConfig import dev.blazelight.p4oc.core.security.CredentialStore import io.mockk.coEvery @@ -24,6 +26,9 @@ import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -44,7 +49,7 @@ class ServerViewModelIssue31Test { @Test fun `connectToRemote persists no-port https url without appending opencode port`() = runTest(dispatcher) { val settingsDataStore = mockk(relaxUnitFun = true) - val connectionManager = mockk() + val connectionRegistry = mockk() val credentialStore = mockk() val discoveryManager = mockk() val savedConfig = slot() @@ -81,19 +86,20 @@ class ServerViewModelIssue31Test { endpointKey = "https://my-host.example.com:443", displayName = "Remote Server", ) - coEvery { connectionManager.connect(any(), any()) } returns Result.success(emptyList()) + coEvery { connectionRegistry.connectAndAwait(any(), any()) } returns Result.success(emptyList()) every { discoveryManager.discoveredServers } returns MutableStateFlow(emptyList()) every { discoveryManager.discoveryState } returns MutableStateFlow(DiscoveryState.IDLE) val viewModel = ServerViewModel( settingsDataStore = settingsDataStore, - connectionManager = connectionManager, + serverConnectionRegistry = connectionRegistry, credentialStore = credentialStore, mdnsDiscoveryManager = discoveryManager, ) advanceUntilIdle() viewModel.setRemoteUrl("https://my-host.example.com") + viewModel.setPassword("secret") viewModel.connectToRemote() advanceUntilIdle() @@ -123,4 +129,65 @@ class ServerViewModelIssue31Test { assertEquals("https://my-host.example.com", recentUrl.captured) assertEquals("https://my-host.example.com", savedUrl.captured) } + + @Test + fun `public http with credentials is rejected before connection`() = runTest(dispatcher) { + val settingsDataStore = mockk() + val connectionRegistry = mockk() + val discoveryManager = mockk() + + every { settingsDataStore.recentServers } returns flowOf(emptyList()) + every { settingsDataStore.savedServers } returns flowOf(emptyList()) + coEvery { settingsDataStore.getLastConnection() } returns null + every { discoveryManager.discoveredServers } returns MutableStateFlow(emptyList()) + every { discoveryManager.discoveryState } returns MutableStateFlow(DiscoveryState.IDLE) + + val viewModel = ServerViewModel( + settingsDataStore = settingsDataStore, + serverConnectionRegistry = connectionRegistry, + credentialStore = mockk(), + mdnsDiscoveryManager = discoveryManager, + ) + advanceUntilIdle() + + viewModel.setRemoteUrl("http://example.com:4096") + viewModel.setPassword("secret") + viewModel.connectToRemote() + advanceUntilIdle() + + coVerify(exactly = 0) { connectionRegistry.connectAndAwait(any(), any()) } + assertFalse(viewModel.uiState.value.isConnecting) + assertTrue(viewModel.uiState.value.error.orEmpty().contains("HTTPS")) + } + + @Test + fun `failed remote connection is neither saved nor navigated`() = runTest(dispatcher) { + val settingsDataStore = mockk() + val connectionRegistry = mockk() + val discoveryManager = mockk() + every { settingsDataStore.recentServers } returns flowOf(emptyList()) + every { settingsDataStore.savedServers } returns flowOf(emptyList()) + every { discoveryManager.discoveredServers } returns MutableStateFlow(emptyList()) + every { discoveryManager.discoveryState } returns MutableStateFlow(DiscoveryState.IDLE) + coEvery { connectionRegistry.connectAndAwait(any(), any()) } returns + Result.failure(IllegalStateException("offline")) + val viewModel = ServerViewModel( + settingsDataStore = settingsDataStore, + serverConnectionRegistry = connectionRegistry, + credentialStore = mockk(), + mdnsDiscoveryManager = discoveryManager, + ) + + viewModel.setRemoteUrl("https://offline.example.com") + viewModel.connectToRemote() + advanceUntilIdle() + + coVerify(exactly = 0) { settingsDataStore.saveLastConnection(any(), any()) } + coVerify(exactly = 0) { settingsDataStore.addRecentServer(any(), any(), any(), any(), any()) } + coVerify(exactly = 0) { + settingsDataStore.addSavedServer(any(), any(), any(), any(), any(), any(), any(), any()) + } + assertFalse(viewModel.uiState.value.isConnected) + assertNull(viewModel.uiState.value.navigationDestination) + } } diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/screens/sessions/SessionListViewModelTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/screens/sessions/SessionListViewModelTest.kt index 2cc1be45..446f62d2 100644 --- a/app/src/test/java/dev/blazelight/p4oc/ui/screens/sessions/SessionListViewModelTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/ui/screens/sessions/SessionListViewModelTest.kt @@ -44,7 +44,7 @@ class SessionListViewModelTest { val repository = repository(client) val viewModel = SessionListViewModel(repository) - viewModel.createSession(title = "new") + viewModel.createSession(title = "new", directory = null) advanceUntilIdle() assertNull(viewModel.uiState.value.error) @@ -125,7 +125,10 @@ class SessionListViewModelTest { advanceUntilIdle() assertEquals(SessionSearchStatus.Failed, viewModel.uiState.value.searchStatus) - assertEquals("Search failed: network down", viewModel.uiState.value.searchError) + assertEquals( + "Could not search sessions. Check the connection and try again.", + viewModel.uiState.value.searchError, + ) repository.close() } @@ -304,6 +307,92 @@ class SessionListViewModelTest { assertEquals(SessionSearchStatus.Current, recreated.uiState.value.searchStatus) repository.close() } + + @Test + fun oversizedRestoredState_isBoundedAtSemanticBoundaries() = runTest(dispatcher) { + val queries = HashMap() + val expanded = HashMap>() + val recency = ArrayList() + repeat(SessionListViewModel.MAX_SAVED_CONTEXTS + 4) { index -> + val context = "/project-$index" + queries[context] = "q".repeat(SessionListViewModel.MAX_SEARCH_QUERY_CHARS + 20) + expanded[context] = ArrayList( + List(SessionListViewModel.MAX_EXPANDED_SESSION_IDS_PER_CONTEXT + 5) { "id-$index-$it" }, + ) + recency += context + } + val handle = SavedStateHandle( + mapOf( + "session_list_search_queries" to queries, + "session_list_expanded_sessions" to expanded, + "session_list_context_recency" to recency, + ), + ) + val repository = repository(FakeWorkspaceClient()) + val viewModel = SessionListViewModel(repository, handle) + advanceUntilIdle() + + viewModel.updateSearchDirectory("/project-19") + advanceUntilIdle() + + assertEquals(SessionListViewModel.MAX_SEARCH_QUERY_CHARS, viewModel.uiState.value.searchQuery.length) + assertEquals( + (5 until 69).map { "id-19-$it" }.toSet(), + viewModel.uiState.value.expandedSessionIds, + ) + assertEquals( + SessionListViewModel.MAX_SAVED_CONTEXTS, + handle.get>("session_list_search_queries")?.size, + ) + assertEquals( + SessionListViewModel.MAX_SAVED_CONTEXTS, + handle.get>>("session_list_expanded_sessions")?.size, + ) + repository.close() + } + + @Test + fun runtimeState_capsQueryContextsAndMostRecentExpandedIds() = runTest(dispatcher) { + val client = FakeWorkspaceClient() + val repository = repository(client) + val handle = SavedStateHandle() + val viewModel = SessionListViewModel(repository, handle) + advanceUntilIdle() + + repeat(SessionListViewModel.MAX_SAVED_CONTEXTS + 2) { index -> + viewModel.updateSearchQuery("query-$index", "/project-$index") + } + val longQuery = "x".repeat(SessionListViewModel.MAX_SEARCH_QUERY_CHARS + 50) + viewModel.updateSearchQuery(longQuery, "/project-17") + advanceTimeBy(300) + advanceUntilIdle() + repeat(SessionListViewModel.MAX_EXPANDED_SESSION_IDS_PER_CONTEXT + 3) { index -> + viewModel.toggleSessionExpanded("session-$index") + } + + val persistedQueries = handle + .get>("session_list_search_queries") + .orEmpty() + val persistedExpanded = handle + .get>>("session_list_expanded_sessions") + .orEmpty() + assertEquals(SessionListViewModel.MAX_SAVED_CONTEXTS, persistedQueries.size) + assertFalse("/project-0" in persistedQueries) + assertFalse("/project-1" in persistedQueries) + assertEquals(SessionListViewModel.MAX_SEARCH_QUERY_CHARS, viewModel.uiState.value.searchQuery.length) + assertTrue( + client.listSessionsCallsLog + .filter { it.search != null } + .all { it.search!!.length <= SessionListViewModel.MAX_SEARCH_QUERY_CHARS }, + ) + assertEquals( + (3 until 67).map { "session-$it" }, + persistedExpanded.getValue("/project-17"), + ) + assertEquals(persistedExpanded.getValue("/project-17").toSet(), viewModel.uiState.value.expandedSessionIds) + repository.close() + } + private fun repository(client: FakeWorkspaceClient): SessionRepositoryImpl = SessionRepositoryImpl( client = client, messageMapper = MessageMapper(Json { ignoreUnknownKeys = true }), diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/screens/settings/ModelControlsViewModelTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/screens/settings/ModelControlsViewModelTest.kt index 139b3b8e..5aba2e48 100644 --- a/app/src/test/java/dev/blazelight/p4oc/ui/screens/settings/ModelControlsViewModelTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/ui/screens/settings/ModelControlsViewModelTest.kt @@ -1,21 +1,28 @@ package dev.blazelight.p4oc.ui.screens.settings -import dev.blazelight.p4oc.core.network.ConnectionManager -import dev.blazelight.p4oc.core.network.OpenCodeApi +import dev.blazelight.p4oc.core.network.ServerConnectionRegistry +import dev.blazelight.p4oc.data.remote.dto.ConfigDto import dev.blazelight.p4oc.data.remote.dto.ModelDto import dev.blazelight.p4oc.data.remote.dto.ModelInput import dev.blazelight.p4oc.data.remote.dto.ProviderDto import dev.blazelight.p4oc.data.remote.dto.ProvidersResponseDto -import dev.blazelight.p4oc.data.remote.dto.SetActiveModelRequest +import dev.blazelight.p4oc.data.workspace.WorkspaceClient +import dev.blazelight.p4oc.domain.model.OpenCodeEvent +import dev.blazelight.p4oc.domain.server.ScopedEvent +import dev.blazelight.p4oc.domain.server.ServerGeneration +import dev.blazelight.p4oc.domain.server.ServerRef +import dev.blazelight.p4oc.domain.server.WorkspaceKey +import dev.blazelight.p4oc.domain.workspace.Workspace import dev.blazelight.p4oc.ui.screens.chat.ModelSelectionCoordinator import io.mockk.coEvery import io.mockk.coVerify -import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runCurrent @@ -23,20 +30,20 @@ import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) class ModelControlsViewModelTest { private val dispatcher = StandardTestDispatcher() - private val connectionManager: ConnectionManager = mockk() - private val api: OpenCodeApi = mockk() + private val workspaceClient: WorkspaceClient = mockk() @Before fun setUp() { Dispatchers.setMain(dispatcher) - every { connectionManager.getApi() } returns api } @After @@ -46,15 +53,16 @@ class ModelControlsViewModelTest { @Test fun selectModel_successUpdatesSelectedStateAndClearsPreviousError() = runTest(dispatcher) { - coEvery { api.getProviders() } returns providersResponse() - coEvery { api.setActiveModel(activeModelRequest("anthropic", "claude-3")) } returns false - val viewModel = ModelControlsViewModel(connectionManager) + coEvery { workspaceClient.getProviders() } returns providersResponse() + coEvery { workspaceClient.updateCurrentModel("anthropic/claude-3") } throws + IllegalStateException("config write failed") + val viewModel = ModelControlsViewModel(workspaceClient) advanceUntilIdle() viewModel.selectModel("claude-3") advanceUntilIdle() - assertEquals("Failed to set active model", viewModel.state.value.error) + assertEquals("Could not update the model. Try again.", viewModel.state.value.error) - coEvery { api.setActiveModel(activeModelRequest("openai", "gpt-4")) } returns true + coEvery { workspaceClient.updateCurrentModel("openai/gpt-4") } returns ConfigDto(model = "openai/gpt-4") viewModel.selectModel("gpt-4") advanceUntilIdle() @@ -70,10 +78,11 @@ class ModelControlsViewModelTest { coordinator.activeModelChanges.collect(publishedModels::add) } runCurrent() - coEvery { api.getProviders() } returns providersResponse() - coEvery { api.setActiveModel(activeModelRequest("anthropic", "claude-3")) } returns false - coEvery { api.setActiveModel(activeModelRequest("openai", "gpt-4")) } returns true - val viewModel = ModelControlsViewModel(connectionManager, coordinator) + coEvery { workspaceClient.getProviders() } returns providersResponse() + coEvery { workspaceClient.updateCurrentModel("anthropic/claude-3") } throws + IllegalStateException("config write failed") + coEvery { workspaceClient.updateCurrentModel("openai/gpt-4") } returns ConfigDto(model = "openai/gpt-4") + val viewModel = ModelControlsViewModel(workspaceClient, coordinator) advanceUntilIdle() viewModel.selectModel("claude-3") @@ -89,11 +98,11 @@ class ModelControlsViewModelTest { @Test fun selectModel_apiErrorPreservesPreviousSelectionAndShowsMessage() = runTest(dispatcher) { - coEvery { api.getProviders() } returns providersResponse() - coEvery { api.setActiveModel(activeModelRequest("openai", "gpt-4")) } returns true - coEvery { api.setActiveModel(activeModelRequest("anthropic", "claude-3")) } throws + coEvery { workspaceClient.getProviders() } returns providersResponse() + coEvery { workspaceClient.updateCurrentModel("openai/gpt-4") } returns ConfigDto(model = "openai/gpt-4") + coEvery { workspaceClient.updateCurrentModel("anthropic/claude-3") } throws IllegalStateException("server rejected model") - val viewModel = ModelControlsViewModel(connectionManager) + val viewModel = ModelControlsViewModel(workspaceClient) advanceUntilIdle() viewModel.selectModel("gpt-4") advanceUntilIdle() @@ -102,15 +111,16 @@ class ModelControlsViewModelTest { advanceUntilIdle() assertEquals("gpt-4", viewModel.state.value.selectedModelId) - assertEquals("server rejected model", viewModel.state.value.error) + assertEquals("Could not update the model. Try again.", viewModel.state.value.error) } @Test - fun selectModel_falseSuccessPreservesPreviousSelectionAndShowsMessage() = runTest(dispatcher) { - coEvery { api.getProviders() } returns providersResponse() - coEvery { api.setActiveModel(activeModelRequest("openai", "gpt-4")) } returns true - coEvery { api.setActiveModel(activeModelRequest("anthropic", "claude-3")) } returns false - val viewModel = ModelControlsViewModel(connectionManager) + fun selectModel_configFailurePreservesPreviousSelectionAndShowsMessage() = runTest(dispatcher) { + coEvery { workspaceClient.getProviders() } returns providersResponse() + coEvery { workspaceClient.updateCurrentModel("openai/gpt-4") } returns ConfigDto(model = "openai/gpt-4") + coEvery { workspaceClient.updateCurrentModel("anthropic/claude-3") } throws + IllegalStateException("config write failed") + val viewModel = ModelControlsViewModel(workspaceClient) advanceUntilIdle() viewModel.selectModel("gpt-4") advanceUntilIdle() @@ -119,31 +129,31 @@ class ModelControlsViewModelTest { advanceUntilIdle() assertEquals("gpt-4", viewModel.state.value.selectedModelId) - assertEquals("Failed to set active model", viewModel.state.value.error) + assertEquals("Could not update the model. Try again.", viewModel.state.value.error) } @Test - fun selectModel_missingApiDoesNotLeaveOptimisticSelection() = runTest(dispatcher) { - coEvery { api.getProviders() } returns providersResponse() - coEvery { api.setActiveModel(activeModelRequest("openai", "gpt-4")) } returns true - val viewModel = ModelControlsViewModel(connectionManager) + fun selectModel_staleClientFailurePreservesSelection() = runTest(dispatcher) { + coEvery { workspaceClient.getProviders() } returns providersResponse() + coEvery { workspaceClient.updateCurrentModel("openai/gpt-4") } returns ConfigDto(model = "openai/gpt-4") + coEvery { workspaceClient.updateCurrentModel("anthropic/claude-3") } throws + IllegalStateException("stale workspace generation") + val viewModel = ModelControlsViewModel(workspaceClient) advanceUntilIdle() viewModel.selectModel("gpt-4") advanceUntilIdle() - every { connectionManager.getApi() } returns null - viewModel.selectModel("claude-3") advanceUntilIdle() assertEquals("gpt-4", viewModel.state.value.selectedModelId) - assertEquals("Not connected", viewModel.state.value.error) + assertEquals("Could not update the model. Try again.", viewModel.state.value.error) } @Test fun selectModel_missingModelDoesNotLeaveOptimisticSelection() = runTest(dispatcher) { - coEvery { api.getProviders() } returns providersResponse() - coEvery { api.setActiveModel(activeModelRequest("openai", "gpt-4")) } returns true - val viewModel = ModelControlsViewModel(connectionManager) + coEvery { workspaceClient.getProviders() } returns providersResponse() + coEvery { workspaceClient.updateCurrentModel("openai/gpt-4") } returns ConfigDto(model = "openai/gpt-4") + val viewModel = ModelControlsViewModel(workspaceClient) advanceUntilIdle() viewModel.selectModel("gpt-4") advanceUntilIdle() @@ -154,10 +164,83 @@ class ModelControlsViewModelTest { assertEquals("gpt-4", viewModel.state.value.selectedModelId) assertEquals("Model not available", viewModel.state.value.error) coVerify(exactly = 0) { - api.setActiveModel(activeModelRequest("anthropic", "missing-model")) + workspaceClient.updateCurrentModel("anthropic/missing-model") } } + @Test + fun loadModels_failureExposesRetryableLoadStateAndRetryClearsIt() = runTest(dispatcher) { + coEvery { workspaceClient.getProviders() } throws IllegalStateException("offline") + val viewModel = ModelControlsViewModel(workspaceClient) + advanceUntilIdle() + + assertTrue(viewModel.state.value.loadFailed) + assertEquals("Could not load models. Try again.", viewModel.state.value.error) + + coEvery { workspaceClient.getProviders() } returns providersResponse() + viewModel.loadModels() + advanceUntilIdle() + + assertFalse(viewModel.state.value.loadFailed) + assertNull(viewModel.state.value.error) + assertEquals(2, viewModel.state.value.models.size) + } + + @Test + fun contentState_distinguishesNoModelsFromFilteredNoResults() { + assertEquals(ModelListContentState.EMPTY, modelListContentState(ModelControlsState())) + + val populated = ModelControlsState( + models = listOf(ModelInfo(id = "gpt-4", name = "GPT-4", providerId = "openai")), + searchQuery = "claude" + ) + assertEquals(ModelListContentState.NO_RESULTS, modelListContentState(populated)) + assertEquals( + ModelListContentState.NO_RESULTS, + modelListContentState(populated.copy(searchQuery = "", filterProvider = "anthropic")) + ) + assertEquals(ModelListContentState.MODELS, modelListContentState(populated.copy(searchQuery = "gpt"))) + } + + @Test + fun clearSearchAndFilter_restoresAllModels() = runTest(dispatcher) { + coEvery { workspaceClient.getProviders() } returns providersResponse() + val viewModel = ModelControlsViewModel(workspaceClient) + advanceUntilIdle() + viewModel.updateSearchQuery("missing") + viewModel.setFilterProvider("missing-provider") + + viewModel.clearSearchAndFilter() + + assertEquals("", viewModel.state.value.searchQuery) + assertNull(viewModel.state.value.filterProvider) + assertEquals(2, filteredModels(viewModel.state.value).size) + } + + @Test + fun catalogEvents_refreshOnlyOwnedWorkspaceAndCoalesceBursts() = runTest(dispatcher) { + val server = ServerRef.fromEndpoint("https://example.test") + val generation = ServerGeneration(3) + val workspace = Workspace(server, "/owned") + val events = MutableSharedFlow() + val registry = mockk() + coEvery { workspaceClient.getProviders() } returns providersResponse() + io.mockk.every { workspaceClient.workspace } returns workspace + io.mockk.every { workspaceClient.generation } returns generation + io.mockk.every { registry.events(server) } returns events + ModelControlsViewModel(workspaceClient, serverConnectionRegistry = registry) + advanceUntilIdle() + coVerify(exactly = 1) { workspaceClient.getProviders() } + + events.emit(ScopedEvent(server, generation, WorkspaceKey.Directory("/other"), OpenCodeEvent.CatalogUpdated)) + events.emit(ScopedEvent(server, generation, workspace.key, OpenCodeEvent.CatalogUpdated)) + events.emit(ScopedEvent(server, generation, workspace.key, OpenCodeEvent.ModelsRefreshed)) + advanceTimeBy(151) + runCurrent() + + coVerify(exactly = 2) { workspaceClient.getProviders() } + } + private fun providersResponse() = ProvidersResponseDto( all = listOf( ProviderDto( @@ -182,8 +265,4 @@ class ModelControlsViewModelTest { providerId = providerId, name = "Model $id" ) - - private fun activeModelRequest(providerId: String, modelId: String) = SetActiveModelRequest( - model = ModelInput(providerID = providerId, modelID = modelId) - ) } diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/screens/settings/ProviderConfigViewModelTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/screens/settings/ProviderConfigViewModelTest.kt index 5282cc2e..bc5115ba 100644 --- a/app/src/test/java/dev/blazelight/p4oc/ui/screens/settings/ProviderConfigViewModelTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/ui/screens/settings/ProviderConfigViewModelTest.kt @@ -1,15 +1,16 @@ package dev.blazelight.p4oc.ui.screens.settings -import dev.blazelight.p4oc.core.network.ConnectionManager -import dev.blazelight.p4oc.core.network.OpenCodeApi import dev.blazelight.p4oc.data.remote.dto.ConfigDto import dev.blazelight.p4oc.data.remote.dto.ModelInput +import dev.blazelight.p4oc.data.remote.dto.OAuthCallbackRequest +import dev.blazelight.p4oc.data.remote.dto.ProviderAuthAuthorizationDto +import dev.blazelight.p4oc.data.remote.dto.ProviderAuthAuthorizeRequest import dev.blazelight.p4oc.data.remote.dto.ProviderDto import dev.blazelight.p4oc.data.remote.dto.ProvidersResponseDto +import dev.blazelight.p4oc.data.workspace.WorkspaceClient import dev.blazelight.p4oc.ui.screens.chat.ModelSelectionCoordinator import io.mockk.coEvery import io.mockk.coVerify -import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -29,13 +30,12 @@ import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) class ProviderConfigViewModelTest { private val dispatcher = StandardTestDispatcher() - private val connectionManager: ConnectionManager = mockk() - private val api: OpenCodeApi = mockk() + private val workspaceClient: WorkspaceClient = mockk() @Before fun setUp() { Dispatchers.setMain(dispatcher) - every { connectionManager.getApi() } returns api + coEvery { workspaceClient.getProviderAuthMethods() } returns emptyMap() } @After @@ -45,11 +45,11 @@ class ProviderConfigViewModelTest { @Test fun setModel_updatesCurrentModelOnlyAfterUpdateConfigSucceeds() = runTest(dispatcher) { - coEvery { api.getProviders() } returns providersResponse() - coEvery { api.getConfig() } returns ConfigDto(model = "openai/gpt-4") - coEvery { api.updateConfig(ConfigDto(model = "anthropic/claude-3")) } returns + coEvery { workspaceClient.getProviders() } returns providersResponse() + coEvery { workspaceClient.getConfig() } returns ConfigDto(model = "openai/gpt-4") + coEvery { workspaceClient.updateConfig(ConfigDto(model = "anthropic/claude-3")) } returns ConfigDto(model = "anthropic/claude-3") - val viewModel = ProviderConfigViewModel(connectionManager) + val viewModel = ProviderConfigViewModel(workspaceClient) advanceUntilIdle() viewModel.setModel("anthropic", "claude-3") @@ -58,7 +58,7 @@ class ProviderConfigViewModelTest { assertEquals("anthropic/claude-3", viewModel.uiState.value.currentModel) assertNull(viewModel.uiState.value.error) - coVerify { api.updateConfig(ConfigDto(model = "anthropic/claude-3")) } + coVerify { workspaceClient.updateConfig(ConfigDto(model = "anthropic/claude-3")) } } @Test @@ -69,18 +69,18 @@ class ProviderConfigViewModelTest { coordinator.activeModelChanges.collect(publishedModels::add) } runCurrent() - coEvery { api.getProviders() } returns providersResponse() - coEvery { api.getConfig() } returns ConfigDto(model = "openai/gpt-4") - coEvery { api.updateConfig(ConfigDto(model = "anthropic/claude-3")) } throws + coEvery { workspaceClient.getProviders() } returns providersResponse() + coEvery { workspaceClient.getConfig() } returns ConfigDto(model = "openai/gpt-4") + coEvery { workspaceClient.updateConfig(ConfigDto(model = "anthropic/claude-3")) } throws IllegalStateException("config write failed") - val viewModel = ProviderConfigViewModel(connectionManager, coordinator) + val viewModel = ProviderConfigViewModel(workspaceClient, coordinator) advanceUntilIdle() viewModel.setModel("anthropic", "claude-3") runCurrent() assertEquals(emptyList(), publishedModels) - coEvery { api.updateConfig(ConfigDto(model = "anthropic/claude-3")) } returns + coEvery { workspaceClient.updateConfig(ConfigDto(model = "anthropic/claude-3")) } returns ConfigDto(model = "anthropic/claude-3") viewModel.setModel("anthropic", "claude-3") runCurrent() @@ -91,18 +91,70 @@ class ProviderConfigViewModelTest { @Test fun setModel_updateConfigExceptionPreservesPreviousModelAndShowsMessage() = runTest(dispatcher) { - coEvery { api.getProviders() } returns providersResponse() - coEvery { api.getConfig() } returns ConfigDto(model = "openai/gpt-4") - coEvery { api.updateConfig(ConfigDto(model = "anthropic/claude-3")) } throws + coEvery { workspaceClient.getProviders() } returns providersResponse() + coEvery { workspaceClient.getConfig() } returns ConfigDto(model = "openai/gpt-4") + coEvery { workspaceClient.updateConfig(ConfigDto(model = "anthropic/claude-3")) } throws IllegalStateException("config write failed") - val viewModel = ProviderConfigViewModel(connectionManager) + val viewModel = ProviderConfigViewModel(workspaceClient) advanceUntilIdle() viewModel.setModel("anthropic", "claude-3") advanceUntilIdle() assertEquals("openai/gpt-4", viewModel.uiState.value.currentModel) - assertEquals("config write failed", viewModel.uiState.value.error) + assertEquals("Could not set the model. Try again.", viewModel.uiState.value.error) + } + + @Test + fun startAndCompleteOAuth_usesSelectedMethodAndRefreshesProviders() = runTest(dispatcher) { + coEvery { workspaceClient.getProviders() } returns providersResponse() + coEvery { workspaceClient.getConfig() } returns ConfigDto(model = "openai/gpt-4") + coEvery { + workspaceClient.authorizeProvider("anthropic", ProviderAuthAuthorizeRequest(method = 2)) + } returns ProviderAuthAuthorizationDto( + url = "https://example.test/oauth", + method = "code", + instructions = "Paste the authorization code" + ) + coEvery { + workspaceClient.completeProviderOAuth("anthropic", OAuthCallbackRequest(method = 2, code = "secret-code")) + } returns true + val viewModel = ProviderConfigViewModel(workspaceClient) + advanceUntilIdle() + + viewModel.startOAuth("anthropic", 2) + advanceUntilIdle() + assertEquals("anthropic", viewModel.uiState.value.pendingAuthorization?.providerId) + + viewModel.completeOAuth(" secret-code ") + advanceUntilIdle() + + assertNull(viewModel.uiState.value.pendingAuthorization) + assertNull(viewModel.uiState.value.error) + coVerify { + workspaceClient.completeProviderOAuth("anthropic", OAuthCallbackRequest(method = 2, code = "secret-code")) + } + coVerify(exactly = 2) { workspaceClient.getProviders() } + } + + @Test + fun startOAuth_failureDoesNotExposeBackendError() = runTest(dispatcher) { + coEvery { workspaceClient.getProviders() } returns providersResponse() + coEvery { workspaceClient.getConfig() } returns ConfigDto(model = "openai/gpt-4") + coEvery { + workspaceClient.authorizeProvider("anthropic", ProviderAuthAuthorizeRequest(method = 0)) + } throws IllegalStateException("raw backend secret") + val viewModel = ProviderConfigViewModel(workspaceClient) + advanceUntilIdle() + + viewModel.startOAuth("anthropic", 0) + advanceUntilIdle() + + assertEquals( + "Could not start provider authentication. Try again.", + viewModel.uiState.value.error + ) + assertNull(viewModel.uiState.value.pendingAuthorization) } private fun providersResponse() = ProvidersResponseDto( diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/screens/settings/SettingsViewModelRegistryTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/screens/settings/SettingsViewModelRegistryTest.kt new file mode 100644 index 00000000..99fe9b69 --- /dev/null +++ b/app/src/test/java/dev/blazelight/p4oc/ui/screens/settings/SettingsViewModelRegistryTest.kt @@ -0,0 +1,129 @@ +package dev.blazelight.p4oc.ui.screens.settings + +import dev.blazelight.p4oc.core.datastore.ConnectionSettings +import dev.blazelight.p4oc.core.datastore.SettingsDataStore +import dev.blazelight.p4oc.core.network.ConnectionState +import dev.blazelight.p4oc.core.network.ServerConfig +import dev.blazelight.p4oc.core.network.ServerConnectionRegistry +import dev.blazelight.p4oc.domain.server.ServerGeneration +import dev.blazelight.p4oc.domain.server.ServerRef +import dev.blazelight.p4oc.domain.workspace.Workspace +import dev.blazelight.p4oc.ui.workspace.WorkspaceRepositoryOwner +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class SettingsViewModelRegistryTest { + private val dispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun tabSettingsObserveAndDisconnectOnlyTheirExactServerGeneration() = runTest(dispatcher) { + val alpha = ServerRef.fromEndpoint("https://alpha.example.com") + val beta = ServerRef.fromEndpoint("https://beta.example.com") + val generation = ServerGeneration(3) + val alphaState = MutableStateFlow(ConnectionState.Connected) + val betaState = MutableStateFlow(ConnectionState.Connected) + val registry = mockk(relaxed = true) + every { registry.connectionState(alpha, generation) } returns alphaState + every { registry.connectionState(beta) } returns betaState + every { registry.generation(alpha) } returns generation + val settings = settingsDataStore() + coEvery { settings.getLastConnection() } returns + (ServerConfig("https://beta.example.com", "Beta", false) to null) + val owner = owner(alpha, generation) + val viewModel = SettingsViewModel(settings, registry, SettingsConnectionContext.Tab(owner)) + + val collection = backgroundScope.launch(dispatcher) { viewModel.isConnected.collect {} } + advanceUntilIdle() + assertTrue(viewModel.isConnected.value) + + viewModel.disconnect() + advanceUntilIdle() + + verify(exactly = 1) { registry.disconnect(alpha) } + verify(exactly = 0) { registry.disconnect(beta) } + coVerify(exactly = 0) { settings.clearLastConnection() } + assertTrue(betaState.value is ConnectionState.Connected) + collection.cancel() + } + + @Test + fun tabDisconnectClearsLastConnectionOnlyWhenPersistedEndpointMatches() = runTest(dispatcher) { + val server = ServerRef.fromEndpoint("https://alpha.example.com") + val generation = ServerGeneration(7) + val registry = mockk(relaxed = true) + every { registry.connectionState(server, generation) } returns + MutableStateFlow(ConnectionState.Connected) + every { registry.generation(server) } returns generation + val settings = settingsDataStore() + coEvery { settings.getLastConnection() } returns + (ServerConfig("https://alpha.example.com/", "Alpha", false) to null) + val viewModel = SettingsViewModel(settings, registry, SettingsConnectionContext.Tab(owner(server, generation))) + + viewModel.disconnect() + advanceUntilIdle() + + verify(exactly = 1) { registry.disconnect(server) } + coVerify(exactly = 1) { settings.clearLastConnection() } + } + + @Test + fun globalSettingsAreDisconnectedAndCannotGuessARegistryServer() = runTest(dispatcher) { + val registry = mockk(relaxed = true) + val settings = settingsDataStore() + coEvery { settings.getLastConnection() } returns + (ServerConfig("https://alpha.example.com", "Alpha", false) to null) + val viewModel = SettingsViewModel(settings, registry, SettingsConnectionContext.Global) + + viewModel.disconnect() + advanceUntilIdle() + + assertFalse(viewModel.isConnected.value) + verify(exactly = 0) { registry.disconnect(any()) } + coVerify(exactly = 0) { settings.getLastConnection() } + coVerify(exactly = 0) { settings.clearLastConnection() } + } + + private fun settingsDataStore(): SettingsDataStore = mockk(relaxUnitFun = true) { + every { connectionSettings } returns flowOf(ConnectionSettings()) + every { serverUrl } returns flowOf("http://localhost:4096") + every { isLocalServer } returns flowOf(true) + every { themeMode } returns flowOf("system") + } + + private fun owner(server: ServerRef, generation: ServerGeneration): WorkspaceRepositoryOwner { + val owner = mockk() + every { owner.workspace } returns Workspace(server, "/workspace") + every { owner.generation } returns generation + return owner + } +} diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/screens/settings/WorkspaceSettingsViewModelTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/screens/settings/WorkspaceSettingsViewModelTest.kt new file mode 100644 index 00000000..319b0f8d --- /dev/null +++ b/app/src/test/java/dev/blazelight/p4oc/ui/screens/settings/WorkspaceSettingsViewModelTest.kt @@ -0,0 +1,109 @@ +package dev.blazelight.p4oc.ui.screens.settings + +import dev.blazelight.p4oc.data.remote.dto.AgentDto +import dev.blazelight.p4oc.data.remote.dto.McpStatusDto +import dev.blazelight.p4oc.data.workspace.WorkspaceClient +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class WorkspaceSettingsViewModelTest { + private val dispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun agents_useOnlyInjectedWorkspaceClient() = runTest(dispatcher) { + val ownerClient: WorkspaceClient = mockk() + val otherClient: WorkspaceClient = mockk(relaxed = true) + coEvery { ownerClient.getAgents() } returns listOf(AgentDto(name = "owner-agent")) + + val viewModel = AgentsConfigViewModel(ownerClient) + advanceUntilIdle() + + assertEquals(listOf("owner-agent"), viewModel.state.value.agents.map { it.name }) + coVerify(exactly = 1) { ownerClient.getAgents() } + coVerify(exactly = 0) { otherClient.getAgents() } + } + + @Test + fun agents_staleClientFailureIsSafe() = runTest(dispatcher) { + val staleClient: WorkspaceClient = mockk() + coEvery { staleClient.getAgents() } throws IllegalStateException("stale workspace generation") + + val viewModel = AgentsConfigViewModel(staleClient) + advanceUntilIdle() + + assertFalse(viewModel.state.value.isLoading) + assertEquals(emptyList(), viewModel.state.value.agents) + assertEquals("Could not load agents. Check the connection and try again.", viewModel.state.value.error) + } + + @Test + fun agents_retryRetainsErrorUntilSuccessfulReload() = runTest(dispatcher) { + val client: WorkspaceClient = mockk() + coEvery { client.getAgents() } throws IllegalStateException("offline") + + val viewModel = AgentsConfigViewModel(client) + advanceUntilIdle() + + assertEquals("Could not load agents. Check the connection and try again.", viewModel.state.value.error) + + coEvery { client.getAgents() } returns listOf(AgentDto(name = "build")) + viewModel.loadAgents() + advanceUntilIdle() + + assertEquals(null, viewModel.state.value.error) + assertEquals(listOf("build"), viewModel.state.value.agents.map { it.name }) + } + + @Test + fun skills_useOnlyInjectedWorkspaceClient() = runTest(dispatcher) { + val ownerClient: WorkspaceClient = mockk() + val otherClient: WorkspaceClient = mockk(relaxed = true) + coEvery { ownerClient.getMcpStatus() } returns mapOf( + "owner-skill" to McpStatusDto(status = MCP_STATUS_CONNECTED) + ) + + val viewModel = SkillsViewModel(ownerClient) + advanceUntilIdle() + + assertEquals(listOf("owner-skill"), viewModel.state.value.skills.map { it.name }) + coVerify(exactly = 1) { ownerClient.getMcpStatus() } + coVerify(exactly = 0) { otherClient.getMcpStatus() } + } + + @Test + fun skills_staleClientFailureIsSafe() = runTest(dispatcher) { + val staleClient: WorkspaceClient = mockk() + coEvery { staleClient.getMcpStatus() } throws IllegalStateException("stale workspace generation") + + val viewModel = SkillsViewModel(staleClient) + advanceUntilIdle() + + assertFalse(viewModel.state.value.isLoading) + assertEquals(emptyList(), viewModel.state.value.skills) + assertEquals(SkillsErrorKind.ApiError, viewModel.state.value.error?.kind) + } +} diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/screens/terminal/TerminalTranscriptStoreTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/screens/terminal/TerminalTranscriptStoreTest.kt new file mode 100644 index 00000000..ccdf2cbc --- /dev/null +++ b/app/src/test/java/dev/blazelight/p4oc/ui/screens/terminal/TerminalTranscriptStoreTest.kt @@ -0,0 +1,121 @@ +package dev.blazelight.p4oc.ui.screens.terminal + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class TerminalTranscriptStoreTest { + @Test + fun `burst frames remain bounded and ordered`() { + val transcript = BoundedTerminalTranscript(maxChars = 10) + + transcript.append("0123") + transcript.append("4567") + transcript.append("89AB") + + assertEquals(10, transcript.size) + assertEquals("23456789AB", transcript.snapshot()) + } + + @Test + fun `trim does not split a surrogate pair`() { + val transcript = BoundedTerminalTranscript(maxChars = 4) + + transcript.append("ab😀cd") + + assertEquals("😀cd", transcript.snapshot()) + assertFalse(Character.isLowSurrogate(transcript.snapshot().first())) + } + + @Test + fun `trim does not retain low surrogate when pair arrived in separate frames`() { + val transcript = BoundedTerminalTranscript(maxChars = 3) + + transcript.append("ab\uD83D") + transcript.append("\uDE00cd") + + assertEquals("cd", transcript.snapshot()) + assertFalse(Character.isLowSurrogate(transcript.snapshot().first())) + } + + @Test + fun `trim avoids retaining tail of csi escape sequence`() { + val transcript = BoundedTerminalTranscript(maxChars = 5) + + transcript.append("abc\u001b[31mXY") + + assertEquals("XY", transcript.snapshot()) + assertFalse(transcript.snapshot().contains("[31")) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `burst changes debounce to one persistence write and explicit flush saves final state`() = runTest { + val transcript = BoundedTerminalTranscript(maxChars = 64) + val snapshots = mutableListOf() + val persistence = TerminalTranscriptPersistence( + scope = backgroundScope, + debounceMillis = 500, + snapshot = transcript::snapshot, + persist = snapshots::add, + ) + + repeat(100) { + transcript.append(it.toString()) + persistence.changed() + } + runCurrent() + advanceTimeBy(499) + runCurrent() + assertTrue(snapshots.isEmpty()) + + advanceTimeBy(1) + runCurrent() + assertEquals(listOf(transcript.snapshot()), snapshots) + + transcript.append("final") + persistence.changed() + persistence.flushNow() + assertEquals(transcript.snapshot(), snapshots.last()) + assertEquals(2, snapshots.size) + } + + @Test + fun `oversized restored transcript is bounded safely`() { + val transcript = BoundedTerminalTranscript(maxChars = 4, restored = "ab😀cd") + + assertEquals(4, transcript.size) + assertEquals("😀cd", transcript.snapshot()) + } + + @Test + fun `accessible text exposes only bounded tail of visible screen`() { + val visibleScreen = "hidden history must not be supplied\n" + "x".repeat(20) + + val result = boundedVisibleTerminalText(visibleScreen, maxChars = 10) + + assertEquals("x".repeat(10), result) + assertEquals(10, result.length) + } + + @Test + fun `accessible text removes empty screen padding`() { + val result = boundedVisibleTerminalText("\n prompt ready \n \n", maxChars = 100) + + assertEquals(" prompt ready", result) + } + + @Test + fun `accessible text cap does not split surrogate pair`() { + val result = boundedVisibleTerminalText("abc😀de", maxChars = 4) + + assertEquals("😀de", result) + assertEquals(4, result.length) + assertFalse(Character.isLowSurrogate(result.first())) + } +} diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/tabs/NotificationRouteOwnershipTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/tabs/NotificationRouteOwnershipTest.kt new file mode 100644 index 00000000..272e981c --- /dev/null +++ b/app/src/test/java/dev/blazelight/p4oc/ui/tabs/NotificationRouteOwnershipTest.kt @@ -0,0 +1,74 @@ +package dev.blazelight.p4oc.ui.tabs + +import dev.blazelight.p4oc.core.datastore.PersistedTab +import dev.blazelight.p4oc.core.datastore.PersistedTabState +import dev.blazelight.p4oc.core.datastore.PersistedWorkspaceKey +import dev.blazelight.p4oc.core.datastore.SavedServer +import dev.blazelight.p4oc.core.notification.NotificationRoute +import dev.blazelight.p4oc.domain.server.ServerRef +import dev.blazelight.p4oc.domain.server.WorkspaceKey +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class NotificationRouteOwnershipTest { + private val route = NotificationRoute( + sessionId = "session", + serverRef = ServerRef.fromEndpointKey("https://saved.example"), + workspaceKey = WorkspaceKey.Global, + ) + + @Test + fun `removed server notification route is rejected`() { + assertNull(findSavedServerForNotification(route, emptyList())) + assertNull(findSavedServerForNotification(route, listOf(server("https://other.example")))) + } + + @Test + fun `current saved server notification route resolves authenticated owner`() { + val saved = server("https://saved.example") + + assertEquals(saved, findSavedServerForNotification(route, listOf(saved))) + } + + @Test + fun `equal session ids only match exact server and workspace tab`() { + val manager = TabManager() + manager.restoreState( + state = PersistedTabState( + serverEndpointKey = "https://saved.example", + activeTabId = null, + tabs = listOf( + PersistedTab( + id = "other", + startRoute = "chat/session", + sessionId = "session", + serverEndpointKey = "https://other.example", + workspaceKey = PersistedWorkspaceKey(PersistedWorkspaceKey.Type.GLOBAL), + ), + PersistedTab( + id = "exact", + startRoute = "chat/session", + sessionId = "session", + serverEndpointKey = "https://saved.example", + workspaceKey = PersistedWorkspaceKey(PersistedWorkspaceKey.Type.GLOBAL), + ), + ), + ), + availableServers = mapOf( + "https://other.example" to ServerRef.fromEndpointKey("https://other.example"), + "https://saved.example" to ServerRef.fromEndpointKey("https://saved.example"), + ), + ) + + assertEquals("exact", manager.findTabByNotificationRoute(route)?.id) + assertNull(manager.findTabByNotificationRoute(route.copy(workspaceKey = WorkspaceKey.Directory("/other")))) + } + + private fun server(endpointKey: String) = SavedServer( + id = endpointKey, + endpoint = endpointKey, + endpointKey = endpointKey, + displayName = endpointKey, + ) +} diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/tabs/PendingStartDispositionTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/tabs/PendingStartDispositionTest.kt new file mode 100644 index 00000000..18e3ed5e --- /dev/null +++ b/app/src/test/java/dev/blazelight/p4oc/ui/tabs/PendingStartDispositionTest.kt @@ -0,0 +1,69 @@ +package dev.blazelight.p4oc.ui.tabs + +import dev.blazelight.p4oc.core.network.ConnectionState +import org.junit.Assert.assertEquals +import org.junit.Test + +class PendingStartDispositionTest { + @Test + fun `removed saved server terminates before considering connection or api`() { + assertEquals( + PendingStartDisposition.SavedServerMissing, + pendingStartDisposition( + savedServerExists = false, + connectionState = ConnectionState.Connected, + apiAvailable = true, + ), + ) + } + + @Test + fun `connection error is a recoverable terminal state`() { + assertEquals( + PendingStartDisposition.ConnectionFailed, + pendingStartDisposition( + savedServerExists = true, + connectionState = ConnectionState.Error("sensitive transport detail"), + apiAvailable = false, + ), + ) + } + + @Test + fun `connecting and disconnected states wait for registry transition`() { + listOf(ConnectionState.Connecting, ConnectionState.Disconnected, null).forEach { state -> + assertEquals( + PendingStartDisposition.WaitForConnection, + pendingStartDisposition( + savedServerExists = true, + connectionState = state, + apiAvailable = false, + ), + ) + } + } + + @Test + fun `connected state without owned api offers recovery rather than waiting`() { + assertEquals( + PendingStartDisposition.ApiUnavailable, + pendingStartDisposition( + savedServerExists = true, + connectionState = ConnectionState.Connected, + apiAvailable = false, + ), + ) + } + + @Test + fun `work runs only when saved server connection and api are all present`() { + assertEquals( + PendingStartDisposition.Run, + pendingStartDisposition( + savedServerExists = true, + connectionState = ConnectionState.Connected, + apiAvailable = true, + ), + ) + } +} diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/tabs/TabManagerPersistenceTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/tabs/TabManagerPersistenceTest.kt index 2bd856b2..6b2dbaa2 100644 --- a/app/src/test/java/dev/blazelight/p4oc/ui/tabs/TabManagerPersistenceTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/ui/tabs/TabManagerPersistenceTest.kt @@ -124,6 +124,125 @@ class TabManagerPersistenceTest { assertEquals(WorkspaceKey.Global, workTabs[1].workspaceKey) } + @Test + fun `restoreState skips workspace records with missing required values`() { + val manager = TabManager() + val state = PersistedTabState( + serverEndpointKey = server.endpointKey, + activeTabId = "missing-directory", + tabs = listOf( + PersistedTab( + id = "missing-directory", + startRoute = Screen.Files.route, + workspaceKey = PersistedWorkspaceKey(PersistedWorkspaceKey.Type.DIRECTORY, null), + ), + PersistedTab( + id = "missing-session", + startRoute = Screen.Sessions.route, + workspaceKey = PersistedWorkspaceKey(PersistedWorkspaceKey.Type.SESSION_SCOPED, null), + ), + PersistedTab( + id = "valid-tab", + startRoute = Screen.Sessions.route, + workspaceKey = PersistedWorkspaceKey(PersistedWorkspaceKey.Type.GLOBAL), + ), + ), + ) + + val result = manager.restoreState(state, server) + + assertTrue(result is RestoreResult.Restored) + assertEquals(1, (result as RestoreResult.Restored).count) + assertEquals(listOf(TabInstance.HOME_TAB_ID, "valid-tab"), manager.tabs.value.map { it.id }) + assertEquals("valid-tab", manager.activeTabId.value) + } + + @Test + fun `restoreState retains first valid duplicate ID and honors it as active`() { + val manager = TabManager() + val state = PersistedTabState( + serverEndpointKey = server.endpointKey, + activeTabId = "duplicate", + tabs = listOf( + PersistedTab( + id = "duplicate", + startRoute = Screen.Files.route, + workspaceKey = PersistedWorkspaceKey(PersistedWorkspaceKey.Type.DIRECTORY, "/first"), + ), + PersistedTab( + id = "duplicate", + startRoute = Screen.Sessions.route, + workspaceKey = PersistedWorkspaceKey(PersistedWorkspaceKey.Type.DIRECTORY, "/second"), + ), + ), + ) + + val result = manager.restoreState(state, server) + + assertTrue(result is RestoreResult.Restored) + assertEquals(1, (result as RestoreResult.Restored).count) + val workTab = manager.tabs.value.single { !it.isPinnedHome } + assertEquals("duplicate", workTab.id) + assertEquals("/first", workTab.workspaceDirectory) + assertEquals(Screen.Files.route, workTab.startRoute) + assertEquals("duplicate", manager.activeTabId.value) + } + + @Test + fun `restoreState lets valid occurrence follow malformed duplicate ID`() { + val manager = TabManager() + val state = PersistedTabState( + serverEndpointKey = server.endpointKey, + activeTabId = "duplicate", + tabs = listOf( + PersistedTab( + id = "duplicate", + startRoute = Screen.Files.route, + workspaceKey = PersistedWorkspaceKey(PersistedWorkspaceKey.Type.DIRECTORY, null), + ), + PersistedTab( + id = "duplicate", + startRoute = Screen.Sessions.route, + workspaceKey = PersistedWorkspaceKey(PersistedWorkspaceKey.Type.DIRECTORY, "/valid"), + ), + ), + ) + + val result = manager.restoreState(state, server) + + assertTrue(result is RestoreResult.Restored) + assertEquals(1, (result as RestoreResult.Restored).count) + assertEquals("/valid", manager.tabs.value.single { !it.isPinnedHome }.workspaceDirectory) + assertEquals("duplicate", manager.activeTabId.value) + } + + @Test + fun `restoreState rejects work tab using reserved Home identity`() { + val manager = TabManager() + val state = PersistedTabState( + serverEndpointKey = server.endpointKey, + activeTabId = TabInstance.HOME_TAB_ID, + tabs = listOf( + PersistedTab( + id = TabInstance.HOME_TAB_ID, + startRoute = Screen.Files.route, + workspaceKey = PersistedWorkspaceKey(PersistedWorkspaceKey.Type.DIRECTORY, "/corrupt"), + ), + PersistedTab( + id = "valid-tab", + startRoute = Screen.Sessions.route, + workspaceKey = PersistedWorkspaceKey(PersistedWorkspaceKey.Type.GLOBAL), + ), + ), + ) + + val result = manager.restoreState(state, server) + + assertTrue(result is RestoreResult.Restored) + assertEquals(listOf(TabInstance.HOME_TAB_ID, "valid-tab"), manager.tabs.value.map { it.id }) + assertEquals(TabInstance.HOME_TAB_ID, manager.activeTabId.value) + } + @Test fun `restoreState reports missing server without restoring wrong server`() { val manager = TabManager() @@ -423,6 +542,12 @@ class TabManagerPersistenceTest { assertEquals(listOf("terminal/pty-1"), manager.findTerminalTabs(server, workspace).map { it.startRoute }) } + @Test + fun `dynamic terminal and diff route arguments are encoded`() { + assertEquals("terminal/pty%2Fwith%20space", Screen.Terminal.createRoute("pty/with space")) + assertEquals("session_diff/session%2Fwith%20space", Screen.SessionDiff.createRoute("session/with space")) + } + @Test fun `createPtyRequestForWorkspace uses target workspace cwd`() { assertEquals("/repo", createPtyRequestForWorkspace(WorkspaceKey.Directory("/repo")).cwd)