diff --git a/app/src/androidTest/java/dev/intent/app/notes/NoteTaskUiTest.kt b/app/src/androidTest/java/dev/intent/app/notes/NoteTaskUiTest.kt new file mode 100644 index 0000000..4bb583d --- /dev/null +++ b/app/src/androidTest/java/dev/intent/app/notes/NoteTaskUiTest.kt @@ -0,0 +1,192 @@ +package dev.intent.app.notes + +import android.graphics.Bitmap +import androidx.compose.material3.Surface +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsOn +import androidx.compose.ui.test.isToggleable +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import dev.intent.app.design.IntentTheme +import dev.intent.app.markdown.MarkdownContent +import dev.intent.app.testing.createIntentComposeRule +import dev.intent.core.protocol.models.Note +import dev.intent.core.protocol.models.NoteMetadata +import dev.intent.core.protocol.models.NoteTaskRow +import dev.intent.core.protocol.models.TaskMetadata +import java.io.File +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class NoteTaskUiTest { + @get:Rule val compose = createIntentComposeRule() + + @Test + fun plainTaskAppearsOnceBetweenItsSurroundingProse() { + val task = mutableStateOf(NoteTaskRow(3, "Check the emulator", "todo")) + compose.setContent { + IntentTheme { + NoteReaderScreen( + NoteReaderState( + note = Note( + "spec", + "Plan", + content = "Before\n\n- [ ] Check the emulator\n\nAfter" + ), + tasks = listOf(task.value), + loading = false + ), + {}, + onTaskStatus = { row, status -> task.value = row.copy(status = status) } + ) + } + } + compose.onAllNodesWithText("Check the emulator").assertCountEquals(1) + val before = compose.onNodeWithText("Before").fetchSemanticsNode().boundsInRoot + val checkbox = compose.onNode(isToggleable()).fetchSemanticsNode().boundsInRoot + val after = compose.onNodeWithText("After").fetchSemanticsNode().boundsInRoot + assertTrue(before.bottom <= checkbox.top) + assertTrue(checkbox.bottom <= after.top) + compose.onNode(isToggleable()).performClick().assertIsOn() + compose.runOnIdle { assertEquals("done", task.value.status) } + } + + @Test + fun linkedTaskUsesLiveStatusAndNavigatesWithoutDuplicatingTheCheckbox() { + val dark = mutableStateOf(false) + val linked = mutableStateOf( + Note("task", "Live task title", metadata = NoteMetadata(TaskMetadata("waiting"))) + ) + var selected: String? = null + var updated: Pair? = null + compose.setContent { + IntentTheme(dark.value) { + Surface { + NoteReaderScreen( + NoteReaderState( + note = Note( + "spec", + "Task plan", + content = "Before\n\n" + + "- [ ] [Old title](intent://local/task/task)\n" + + " - Nested detail\n\n Extra paragraph\n\nAfter", + tags = listOf("planning") + ), + tasks = listOf( + NoteTaskRow(3, "Old title", "todo", taskNoteId = "task") + ), + loading = false, + relatedNotes = listOf(linked.value) + ), + {}, + onNoteSelected = { selected = it }, + onLinkedTaskStatus = { id, status -> updated = id to status } + ) + } + } + } + compose.onAllNodes(isToggleable()).assertCountEquals(0) + compose.onNodeWithText("Old title").assertDoesNotExist() + compose.onNodeWithText("Nested detail").assertIsDisplayed() + compose.onNodeWithText("Extra paragraph").assertIsDisplayed() + compose.onNodeWithText("Live task title").performClick() + compose.runOnIdle { assertEquals("task", selected) } + compose.onNodeWithText("Waiting").performClick() + compose.onNodeWithText("Complete").performClick() + compose.runOnIdle { + assertEquals("task" to "complete", updated) + linked.value = linked.value.copy(metadata = NoteMetadata(TaskMetadata("complete"))) + } + compose.onNodeWithText("Complete").assertIsDisplayed() + listOf(false, true).forEach { night -> + compose.runOnIdle { dark.value = night } + compose.waitForIdle() + compose.onNodeWithText("Old title").assertDoesNotExist() + compose.onNodeWithText("Nested detail").assertIsDisplayed() + compose.onNodeWithText("Extra paragraph").assertIsDisplayed() + screenshot("note-inline-linked-${if (night) "dark" else "light"}") + } + } + + @Test + fun taskMetadataShowsAssignmentAndNavigableRelations() { + var selectedNote: String? = null + var selectedAgent: String? = null + val task = TaskMetadata( + "blocked", + assignedAgentIds = listOf("agent-one"), + dependsOn = listOf("dependency"), + conflictsWith = listOf("conflict"), + unmetDependsOn = listOf("dependency") + ) + val related = listOf( + Note("dependency", "Dependency", metadata = NoteMetadata(TaskMetadata("waiting"))), + Note( + "conflict", + "Conflicting work", + metadata = NoteMetadata(TaskMetadata("in_progress")) + ), + Note( + "dependent", + "Follow-up", + metadata = NoteMetadata(TaskMetadata("not_started", dependsOn = listOf("task"))) + ) + ) + compose.setContent { + IntentTheme { + NoteReaderScreen( + NoteReaderState( + note = Note("task", "Assigned task", metadata = NoteMetadata(task)), + loading = false, + relatedNotes = related + ), + {}, + onNoteSelected = { selectedNote = it }, + onAgentSelected = { selectedAgent = it } + ) + } + } + compose.onNodeWithText("agent-one").performClick() + compose.runOnIdle { assertEquals("agent-one", selectedAgent) } + compose.onNodeWithText("Dependency · Waiting · Unmet dependency") + .performScrollTo().performClick() + compose.runOnIdle { assertEquals("dependency", selectedNote) } + compose.onNodeWithText("Follow-up · Not started").performScrollTo().assertIsDisplayed() + compose.onNodeWithText("Conflicting work · In progress").performScrollTo().performClick() + compose.runOnIdle { assertEquals("conflict", selectedNote) } + screenshot("note-task-relations-light") + } + + @Test + fun transcriptMarkdownKeepsReadOnlyCheckboxes() { + compose.setContent { IntentTheme { MarkdownContent("- [ ] Read only") } } + compose.onNodeWithText("Read only").assertIsDisplayed() + compose.onAllNodes(isToggleable()).assertCountEquals(0) + } + + private fun screenshot(name: String) { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val directory = InstrumentationRegistry.getArguments().getString("additionalTestOutputDir") + ?.let(::File) + ?: File( + instrumentation.targetContext.externalMediaDirs.first(), + "additional_test_output" + ) + check(directory.mkdirs() || directory.isDirectory) + val bitmap = checkNotNull(instrumentation.uiAutomation.takeScreenshot()) + File(directory, "$name.png").outputStream().use { + check(bitmap.compress(Bitmap.CompressFormat.PNG, 100, it)) + } + bitmap.recycle() + } +} diff --git a/app/src/androidTest/java/dev/intent/app/smoke/AssistantTest.kt b/app/src/androidTest/java/dev/intent/app/smoke/AssistantTest.kt index c26ca5d..28edea4 100644 --- a/app/src/androidTest/java/dev/intent/app/smoke/AssistantTest.kt +++ b/app/src/androidTest/java/dev/intent/app/smoke/AssistantTest.kt @@ -1,14 +1,21 @@ package dev.intent.app.smoke import android.graphics.Bitmap +import androidx.activity.OnBackPressedDispatcher +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.isDialog import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -30,8 +37,10 @@ import dev.intent.core.protocol.session.RpcSession import dev.intent.core.protocol.session.SessionState import dev.intent.core.protocol.session.SessionTransport import java.io.File +import java.util.concurrent.CompletableFuture import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.jsonPrimitive import org.junit.Assert.assertEquals @@ -62,7 +71,8 @@ class AssistantTest { action("New conversation") waitText("Conversation thread-2") action("Assistant history") - waitText("Assistant 2 · Selected") + waitText("Assistant 2") + compose.onNodeWithTag("assistant.history.thread-2").assertIsSelected() screenshot("assistant-history") compose.onNodeWithText("Assistant 1").performClick() waitText("Conversation thread-1") @@ -74,13 +84,71 @@ class AssistantTest { assertFalse(server.calls.contains("agent.delete")) compose.onNodeWithContentDescription("Delete Assistant 1").performClick() compose.onNodeWithText("Delete conversation").performClick() - waitText("Assistant 2 · Selected") + waitText("Assistant 2") + compose.onNodeWithTag("assistant.history.thread-2").assertIsSelected() assertEquals(1, server.calls.count { it == "agent.delete" }) assertEquals("__chief__", server.lastWorkspace) } } } + @Test fun historyErrorsStayInTheDialogAndRetryPreservesSelection() { + val failRefresh = AtomicBoolean(false) + lateinit var dispatcher: OnBackPressedDispatcher + AssistantStore({ method, _ -> + when { + method == "agent.delete" -> CompletableFuture().apply { + completeExceptionally(IllegalStateException("Cannot delete conversation")) + } + method == "agent.list" && failRefresh.get() -> + CompletableFuture().apply { + completeExceptionally(IllegalStateException("Cannot refresh conversations")) + } + method == "principal.me" -> CompletableFuture.completedFuture( + obj("""{"id":"owner","isAdministrator":true}""") + ) + else -> CompletableFuture.completedFuture( + obj( + """{"agents":[{"id":"thread","name":"Planning","createdAt":"2026-11-04T12:00:00Z"}]}""" + ) + ) + } + }).use { store -> + compose.setContent { + dispatcher = LocalOnBackPressedDispatcherOwner.current!!.onBackPressedDispatcher + IntentTheme(darkTheme = true) { + Surface(Modifier.fillMaxSize()) { + AssistantScreen(store, {}) { Text("Conversation $it") } + } + } + } + waitText("Conversation thread") + action("Assistant history") + compose.onNodeWithTag("assistant.history.thread").assertIsSelected() + compose.runOnIdle { + failRefresh.set(true) + store.refresh() + } + compose.onNode( + hasText("Cannot refresh conversations") and hasAnyAncestor(isDialog()) + ).assertIsDisplayed() + screenshot("assistant-history-error") + failRefresh.set(false) + compose.onNodeWithText("Retry Assistant").performClick() + compose.onNodeWithText("Cannot refresh conversations").assertDoesNotExist() + compose.onNodeWithTag("assistant.history.thread").assertIsSelected() + compose.onNodeWithContentDescription("Delete Planning").performClick() + compose.onNodeWithText("Delete conversation").performClick() + compose.onNode( + hasText("Cannot delete conversation") and hasAnyAncestor(isDialog()) + ).assertIsDisplayed() + compose.onNodeWithTag("assistant.history.thread").assertIsSelected() + compose.runOnIdle { dispatcher.onBackPressed() } + compose.onNodeWithText("Close history").assertDoesNotExist() + waitText("Conversation thread") + } + } + @Test fun proposalApplyUsesPendingMessageAndShowsOutcome() { withProposal { server -> waitText("Apply") @@ -219,7 +287,7 @@ private class AssistantServer { "agent.create" -> { count++ val agent = - """{"id":"thread-$count","name":"Assistant $count","workspaceId":"__chief__"}""" + """{"id":"thread-$count","name":"Assistant $count","workspaceId":"__chief__","createdAt":"2026-11-04T12:00:00Z"}""" threads.add(0, agent) """{"agent":$agent}""" } diff --git a/app/src/androidTest/java/dev/intent/app/smoke/AttachmentInputTest.kt b/app/src/androidTest/java/dev/intent/app/smoke/AttachmentInputTest.kt index 5841df9..f4a06c5 100644 --- a/app/src/androidTest/java/dev/intent/app/smoke/AttachmentInputTest.kt +++ b/app/src/androidTest/java/dev/intent/app/smoke/AttachmentInputTest.kt @@ -19,13 +19,14 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.captureToImage import androidx.compose.ui.test.hasContentDescription import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.unit.dp import androidx.core.app.ActivityOptionsCompat import androidx.core.content.FileProvider @@ -35,6 +36,7 @@ import dev.intent.app.attachments.PhotoAttachmentStore import dev.intent.app.attachments.PhotoAttachments import dev.intent.app.attachments.PhotoPicker import dev.intent.app.conversation.ConversationComposer +import dev.intent.app.conversation.ConversationSendState import dev.intent.app.conversation.ConversationSendStore import dev.intent.app.conversation.RpcConversationCommands import dev.intent.app.design.IntentTheme @@ -64,6 +66,36 @@ class AttachmentInputTest { @get:Rule val compose = createIntentComposeRule() private val context get() = InstrumentationRegistry.getInstrumentation().targetContext + @Test + fun attachmentOnlyDraftCanSendWhileResponseRuns() { + val attachments = store(UploadFixture()) + attachments.addFiles(listOf(uri("follow-up.txt", "Follow up".toByteArray()).toString())) + compose.waitUntil(TIMEOUT) { attachments.state.value.ready } + var sent: String? = null + compose.setContent { + IntentTheme { + ConversationComposer( + ConversationSendState(running = true), + { + sent = it + false + }, + {}, + { _, _ -> false }, + photos = attachments + ) + } + } + compose.onNodeWithContentDescription("Stop").assertIsDisplayed() + compose.onNodeWithText("Send").assertIsEnabled().performClick() + compose.runOnIdle { + assertEquals("", sent) + assertEquals(1, attachments.state.value.photos.size) + } + screenshot("attachment-only-running") + attachments.close() + } + @Test fun oversizedVideoFromMediaAndFilesShowsIOSMessageWithoutUpload() { val directory = File(context.cacheDir, "attachments").also { it.mkdirs() } @@ -251,7 +283,7 @@ class AttachmentInputTest { }, questionPhotos = question) } } - compose.onNodeWithText("Send answers").performScrollTo() + compose.onNodeWithText("Send answers").assertIsDisplayed() screenshot("attachment-question") compose.onNodeWithText("Send answers").performClick() compose.waitUntil(TIMEOUT) { diff --git a/app/src/androidTest/java/dev/intent/app/smoke/BrowserPreviewStateTest.kt b/app/src/androidTest/java/dev/intent/app/smoke/BrowserPreviewStateTest.kt index eb115de..e827044 100644 --- a/app/src/androidTest/java/dev/intent/app/smoke/BrowserPreviewStateTest.kt +++ b/app/src/androidTest/java/dev/intent/app/smoke/BrowserPreviewStateTest.kt @@ -1,5 +1,6 @@ package dev.intent.app.smoke +import android.graphics.Bitmap import androidx.compose.material3.Text import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.test.assertIsDisplayed @@ -9,10 +10,19 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performImeAction import androidx.compose.ui.test.performTextInput +import androidx.test.platform.app.InstrumentationRegistry import dev.intent.app.browser.BrowserScreen import dev.intent.app.browser.BrowserState -import dev.intent.app.design.IntentTheme +import dev.intent.app.browser.SavedBrowser +import dev.intent.app.browser.SavedBrowsersState +import dev.intent.app.browser.WorkspaceBrowsersScreen +import dev.intent.app.scripts.WorkspaceScriptsScreen +import dev.intent.app.scripts.WorkspaceScriptsState +import dev.intent.app.testing.IntentTestTheme import dev.intent.app.testing.createIntentComposeRule +import dev.intent.core.protocol.models.WorkspaceScriptResource +import dev.intent.core.protocol.models.WorkspaceScriptRuntime +import java.io.File import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test @@ -20,11 +30,83 @@ import org.junit.Test class BrowserPreviewStateTest { @get:Rule val compose = createIntentComposeRule() + @Test + fun savedBrowserRowsOpenAndRemoveAndShowEmptyState() { + val browser = SavedBrowser("preview", "Workspace preview", "http://localhost:5173") + val state = mutableStateOf(SavedBrowsersState(listOf(browser), loading = false)) + val dark = mutableStateOf(false) + var opened: SavedBrowser? = null + compose.setContent { + IntentTestTheme(darkTheme = dark.value) { + WorkspaceBrowsersScreen( + state.value, + {}, + {}, + { opened = it }, + { state.value = SavedBrowsersState(loading = false) }, + {} + ) + } + } + compose.onNodeWithText("Workspace preview").performClick() + assertEquals(browser, opened) + screenshot("browser-resources-light") + compose.runOnIdle { dark.value = true } + screenshot("browser-resources-dark") + compose.onNodeWithText("Remove saved browser").performClick() + compose.onNodeWithText("No saved browsers").assertIsDisplayed() + } + + @Test + fun scriptResourcesOfferStartStopAndPreviewUsingRuntimeState() { + val scripts = listOf( + WorkspaceScriptResource( + "build", + "workspace", + "Build", + "./gradlew build", + "command", + WorkspaceScriptRuntime("idle") + ), + WorkspaceScriptResource( + "dev", + "workspace", + "Dev server", + "npm run dev", + "service", + WorkspaceScriptRuntime("running", detectedUrl = "http://localhost:5173") + ) + ) + val dark = mutableStateOf(false) + var action: Pair? = null + var preview: String? = null + compose.setContent { + IntentTestTheme(darkTheme = dark.value) { + WorkspaceScriptsScreen( + WorkspaceScriptsState(scripts, loading = false, connected = true), + {}, + {}, + { id, running -> action = id to running }, + { preview = it } + ) + } + } + compose.onNodeWithText("Start").performClick() + assertEquals("build" to true, action) + compose.onNodeWithText("Stop").performClick() + assertEquals("dev" to false, action) + compose.onNodeWithText("Open preview").performClick() + assertEquals("http://localhost:5173", preview) + screenshot("script-resources-light") + compose.runOnIdle { dark.value = true } + screenshot("script-resources-dark") + } + @Test fun previewContentSurvivesActivityRecreation() { val state = mutableStateOf(BrowserState(url = "http://localhost:3000")) compose.setContent { - IntentTheme { BrowserScreen(state.value, {}, {}) { Text("Preview page") } } + IntentTestTheme { BrowserScreen(state.value, {}, {}) { Text("Preview page") } } } compose.onNodeWithText("Preview page").assertIsDisplayed() compose.recreateActivity() @@ -42,7 +124,7 @@ class BrowserPreviewStateTest { ) var retried = false compose.setContent { - IntentTheme { + IntentTestTheme { BrowserScreen(state.value, { }, {}, onReload = { retried = true }) { Text("Preview page") } } @@ -63,7 +145,7 @@ class BrowserPreviewStateTest { fun emptyPreviewShowsGuidanceAndAddressEntryOpensTheServer() { var opened = "" compose.setContent { - IntentTheme { BrowserScreen(BrowserState(), {}, { opened = it }) {} } + IntentTestTheme { BrowserScreen(BrowserState(), {}, { opened = it }) {} } } compose.onNodeWithText("Open a workspace preview").assertIsDisplayed() compose.onNodeWithContentDescription("Page back").assertIsNotEnabled() @@ -72,4 +154,17 @@ class BrowserPreviewStateTest { compose.onNodeWithContentDescription("Browser URL").performImeAction() assertEquals("http://localhost:5173/path", opened) } + + private fun screenshot(name: String) { + compose.waitForIdle() + val instrumentation = InstrumentationRegistry.getInstrumentation() + val directory = InstrumentationRegistry.getArguments().getString("additionalTestOutputDir") + ?.let(::File) ?: File(instrumentation.targetContext.cacheDir, "screenshots") + check(directory.mkdirs() || directory.isDirectory) + val bitmap = checkNotNull(instrumentation.uiAutomation.takeScreenshot()) + File(directory, "$name.png").outputStream().use { + check(bitmap.compress(Bitmap.CompressFormat.PNG, 100, it)) + } + bitmap.recycle() + } } diff --git a/app/src/androidTest/java/dev/intent/app/smoke/ConversationControlsTest.kt b/app/src/androidTest/java/dev/intent/app/smoke/ConversationControlsTest.kt index 1cf9a94..029b222 100644 --- a/app/src/androidTest/java/dev/intent/app/smoke/ConversationControlsTest.kt +++ b/app/src/androidTest/java/dev/intent/app/smoke/ConversationControlsTest.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Modifier import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText @@ -21,6 +22,7 @@ import dev.intent.app.conversation.ConversationComposer import dev.intent.app.conversation.ConversationSendState import dev.intent.app.conversation.ConversationSendStore import dev.intent.app.conversation.RpcConversationCommands +import dev.intent.app.conversation.SendStatus import dev.intent.app.design.IntentTheme import dev.intent.app.testing.createIntentComposeRule import dev.intent.core.protocol.session.RpcSession @@ -30,6 +32,8 @@ import java.util.concurrent.atomic.AtomicBoolean import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -76,7 +80,10 @@ class ConversationControlsTest { compose.onNodeWithContentDescription("Stop").performClick() compose.waitUntil(TIMEOUT) { server.requests.any { it.method == "agent.stop" } } compose.onNodeWithContentDescription("Stop").assertIsNotEnabled() - compose.onNodeWithText("Edit").performScrollTo().performClick() + compose.onNodeWithContentDescription( + "Queued message actions" + ).performScrollTo().performClick() + compose.onNodeWithText("Edit").performClick() compose.onNodeWithText( "Edit queued message" ).performTextReplacement("Updated queue text") @@ -96,19 +103,22 @@ class ConversationControlsTest { }.params as JsonObject )["content"] ) - compose.onNodeWithText("Send now").performScrollTo().performClick() + compose.onNodeWithContentDescription( + "Queued message actions" + ).performScrollTo().performClick() + compose.onNodeWithText("Send now").performClick() compose.waitUntil(TIMEOUT) { server.requests.any { it.method == "agent.sendQueuedMessageNow" } } - compose.onNodeWithText("Dismiss questions").performScrollTo().performClick() + compose.onNodeWithContentDescription("Dismiss questions").performClick() compose.onNodeWithText("Dismiss questions?").assertIsDisplayed() screenshot("conversation-dismiss-confirmation") compose.onNodeWithText("Cancel").performClick() assertEquals(0, server.requests.count { it.method == "agent.dismissQuestions" }) - compose.onNodeWithText("Dismiss questions").performScrollTo().performClick() + compose.onNodeWithContentDescription("Dismiss questions").performClick() compose.onNodeWithText("Dismiss").performClick() compose.waitUntil(TIMEOUT) { sends.state.value.questions == null } compose.onNodeWithText("Choose?").assertDoesNotExist() @@ -144,17 +154,21 @@ class ConversationControlsTest { } @Test - fun stopReplacesSendAndPreservesDraftInBothThemes() { + fun stopAndSendStayAvailableAndPreserveRejectedDraftInBothThemes() { val dark = mutableStateOf(false) val state = mutableStateOf(ConversationSendState(running = true)) val draft = ComposerDraft().apply { text = "Keep this draft" } var stops = 0 + val sent = mutableListOf() compose.setContent { IntentTheme(darkTheme = dark.value) { Surface(Modifier.fillMaxSize()) { ConversationComposer( state.value, - { false }, + { + sent.add(it) + false + }, {}, { _, _ -> false }, draft = draft, @@ -174,7 +188,8 @@ class ConversationControlsTest { val theme = if (darkTheme) "dark" else "light" compose.onNodeWithContentDescription("Stop").assertIsDisplayed() compose.onNodeWithText("Stop response").assertDoesNotExist() - compose.onNodeWithText("Send").assertDoesNotExist() + compose.onNodeWithText("Send").assertIsEnabled().performClick() + compose.runOnIdle { assertEquals("Keep this draft", sent.last()) } screenshot("stop-working-$theme", suffix = "") compose.onNodeWithContentDescription("Stop").performClick() compose.onNodeWithContentDescription("Stop").assertIsNotEnabled().performClick() @@ -189,6 +204,65 @@ class ConversationControlsTest { } } + @Test + fun followUpWhileRunningUsesNormalSendAndClearsOnlyOnConfirmation() { + lateinit var server: SmokeServer + val ready = AtomicBoolean() + val responses = mapOf( + "agent.get" to """{"agent":{"id":"agent","name":"Agent","turnInFlight":true}}""", + "agent.getQueue" to """{"success":true,"queue":[]}""", + "agent.getConversation" to """{"agentId":"agent","messages":[]}""", + "agent.sendMessage" to + """{"success":true,"queued":true,"queuedMessage":{"id":"queued","content":"Follow up","queuedAt":"now","position":0}}""" + ) + RpcSession( + { listener -> SmokeServer(listener, responses).also { server = it } }, + JsonObject(emptyMap()), + onState = { ready.set(it == SessionState.READY) } + ).use { session -> + session.connect() + compose.waitUntil(TIMEOUT) { ready.get() } + ConversationSendStore( + RpcConversationCommands(session, "workspace", "agent"), + "agent" + ).use { sends -> + sends.refresh() + compose.waitUntil(TIMEOUT) { sends.state.value.running } + val draft = ComposerDraft("Follow up") + compose.setContent { + val state by sends.state.collectAsState() + IntentTheme { + ConversationComposer( + state, + { sends.send(it, onConfirmed = draft.confirmation()) }, + {}, + { _, _ -> false }, + draft = draft, + clearOnSend = false, + onStop = sends::stopReply + ) + } + } + compose.onNodeWithContentDescription("Stop").assertIsDisplayed() + compose.onNodeWithText("Send").assertIsEnabled().performClick() + compose.waitUntil(TIMEOUT) { + sends.state.value.attempts.singleOrNull()?.status == SendStatus.QUEUED + } + val request = server.requests.single { it.method == "agent.sendMessage" } + val params = request.params as JsonObject + assertEquals(JsonPrimitive("Follow up"), params["content"]) + assertEquals(JsonPrimitive("workspace"), params["workspaceId"]) + assertEquals(JsonPrimitive("agent"), params["agentId"]) + assertFalse(params.containsKey("priority")) + assertFalse(server.requests.any { it.method == "agent.stop" }) + compose.runOnIdle { assertEquals("", draft.text) } + assertTrue(sends.state.value.running) + compose.onNodeWithContentDescription("Stop").assertIsDisplayed() + screenshot("conversation-running-follow-up", suffix = "-light") + } + } + } + private fun responses() = mapOf( "agent.get" to """{"agent":{"id":"agent","name":"Agent","turnInFlight":true,"metadata":{"pendingQuestionsMessageId":"questions"}}}""", diff --git a/app/src/androidTest/java/dev/intent/app/smoke/ConversationStatusTest.kt b/app/src/androidTest/java/dev/intent/app/smoke/ConversationStatusTest.kt index 4fce063..625b200 100644 --- a/app/src/androidTest/java/dev/intent/app/smoke/ConversationStatusTest.kt +++ b/app/src/androidTest/java/dev/intent/app/smoke/ConversationStatusTest.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.asAndroidBitmap import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.captureToImage import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText @@ -36,7 +37,9 @@ import dev.intent.core.protocol.models.WireJson import java.io.File import java.time.Instant import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.put import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -46,6 +49,74 @@ class ConversationStatusTest { @get:Rule val compose = createIntentComposeRule() private val agent = Agent("a", "Coordinator") + @Test fun watchedAgentsNavigateAcrossWorkspacesAndDeletedRowsStayDisabled() { + var target: Pair? = null + compose.setContent { + IntentTheme { + ConversationStatusStrip( + WatchState(), + agent.copy(workspaceId = "current"), + {}, + {}, + {}, + agentWatches = dev.intent.app.status.AgentWatches( + rows = listOf( + dev.intent.app.status.WatchedAgent( + Agent("remote", "Other workspace", "other") + ), + dev.intent.app.status.WatchedAgent( + Agent("gone", "Deleted", "other"), + deleted = true + ) + ) + ), + onWatchedAgent = { workspace, id -> target = workspace to id } + ) + } + } + compose.onNodeWithTag("watched-agent-remote").performClick() + compose.runOnIdle { check(target == ("other" to "remote")) } + compose.onNodeWithTag("watched-agent-gone").assertIsNotEnabled() + screenshot("agent-cross-workspace-watch") + } + + @Test fun agentGroupsExpandWithoutLosingDelegatedRows() { + val rows = listOf( + Agent("root", "Primary agent"), + Agent("child", "Delegated task", parentAgentId = "root", isActive = true), + Agent( + "background", + "Background task", + metadata = buildJsonObject { + put("isBackground", true) + } + ), + Agent("orphan", "Unlinked task", parentAgentId = "missing") + ) + compose.setContent { + IntentTheme { + AgentListScreen( + AgentListState( + agents = rows, + isLoading = false, + retiredAgents = listOf(Agent("old", "Previous task", retiredAt = "now")) + ), + {}, + {} + ) + } + } + compose.onNodeWithText("Delegated task").assertDoesNotExist() + screenshot("agent-groups-collapsed") + compose.onNodeWithText("▸ 1 delegated · 1 running").performClick() + compose.onNodeWithText("▸ Background agents · 1 · 0 running").performClick() + compose.onNodeWithText("▸ Orphaned delegated agents · 1 · 0 running").performClick() + compose.onNodeWithText("▸ Retired agents · 1 · 0 running").performClick() + compose.onNodeWithText("Previous task").assertIsDisplayed() + compose.onNodeWithText("Delegated task").assertIsDisplayed() + screenshot("agent-groups-expanded") + } + @Test fun scriptsHooksAndPrsShowAboveComposerAndOpenDetails() { val selected = mutableStateOf(null) compose.setContent { diff --git a/app/src/androidTest/java/dev/intent/app/smoke/ConversationTranscriptTest.kt b/app/src/androidTest/java/dev/intent/app/smoke/ConversationTranscriptTest.kt index f454496..a6ae666 100644 --- a/app/src/androidTest/java/dev/intent/app/smoke/ConversationTranscriptTest.kt +++ b/app/src/androidTest/java/dev/intent/app/smoke/ConversationTranscriptTest.kt @@ -269,11 +269,11 @@ class ConversationTranscriptTest { } compose.onNodeWithText("Human message").assertIsDisplayed() compose.onNodeWithText(machineText).assertDoesNotExist() - compose.onNodeWithText("Queued: $queuedText").assertDoesNotExist() + compose.onNodeWithText(queuedText).assertDoesNotExist() compose.onNodeWithText("▸ Agent message").performClick() compose.onNodeWithText(machineText).assertIsDisplayed() compose.onNodeWithText("▸ 1 queued background updates").performClick() - compose.onNodeWithText("Queued: $queuedText").assertIsDisplayed() + compose.onNodeWithText(queuedText).assertIsDisplayed() } @Test @@ -346,8 +346,8 @@ class ConversationTranscriptTest { screenshot("queue-compact") compose.onNodeWithText("Monitoring PR", substring = true).assertDoesNotExist() compose.onNodeWithText("▸ 2 queued background updates").performClick() - compose.onNodeWithText("Queued: Monitoring PR intent-hq/android: #5").assertIsDisplayed() - compose.onNodeWithText("Queued: Monitoring PR intent-hq/android: #6").assertIsDisplayed() + compose.onNodeWithText("Monitoring PR intent-hq/android: #5").assertIsDisplayed() + compose.onNodeWithText("Monitoring PR intent-hq/android: #6").assertIsDisplayed() screenshot("queue-expanded") compose.onNodeWithText("▾ 2 queued background updates").performClick() compose.onNodeWithText("Monitoring PR", substring = true).assertDoesNotExist() diff --git a/app/src/androidTest/java/dev/intent/app/smoke/CoreFlowsTest.kt b/app/src/androidTest/java/dev/intent/app/smoke/CoreFlowsTest.kt index 5e4145e..37524c1 100644 --- a/app/src/androidTest/java/dev/intent/app/smoke/CoreFlowsTest.kt +++ b/app/src/androidTest/java/dev/intent/app/smoke/CoreFlowsTest.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.performTextReplacement import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import dev.intent.app.comments.CommentScreen @@ -63,24 +64,32 @@ class CoreFlowsTest { fun manualPairingConnectsToWorkspaceList() { val repository = MemoryConnections() lateinit var server: SmokeServer + lateinit var connectionState: (SessionState) -> Unit ConnectionController(repository, { saved, callback -> + connectionState = callback createConnectionSession(saved, callback, transport = { listener -> SmokeServer(listener).also { server = it } }) }).use { controller -> content { ConnectionRoot(controller, {}) } compose.onNodeWithText("Scan QR code").assertIsDisplayed() + screenshots("pairing-landing") compose.onNodeWithText("Enter connection details").performClick() compose.onNodeWithText("Review connection").assertIsNotEnabled() - compose.onNodeWithText("Host").performTextInput("desktop.test") - compose.onNodeWithText("Port").performTextInput("443") - compose.onNodeWithText("Pairing token").performTextInput("smoke-token") + compose.onNodeWithText("Host / Tailcat address").performTextInput("desktop.test") + compose.onNodeWithText("Port").performTextReplacement("443") + compose.onNodeWithText( + "Pairing token" + ).performScrollTo().performTextInput("smoke-token") + compose.onNodeWithText("Show token").performClick() + compose.onNodeWithText("Hide token").assertIsDisplayed().performClick() compose.onNodeWithText("Certificate fingerprint (optional)") .performScrollTo().performTextInput("AB".repeat(32)) screenshots("pairing") compose.onNodeWithText("Review connection").performScrollTo().performClick() compose.waitUntil(TIMEOUT) { controller.state.value.pending != null } compose.onNodeWithText("Trust and connect?").assertIsDisplayed() + screenshots("pairing-trust") assertTrue(repository.list().isEmpty()) compose.onNodeWithText("Trust and connect").performClick() compose.waitUntil(TIMEOUT) { controller.state.value.session != null } @@ -97,6 +106,22 @@ class CoreFlowsTest { } ) screenshots("paired-workspaces") + val admitted = controller.state.value.session + connectionState(SessionState.DISCONNECTED) + compose.waitUntil(TIMEOUT) { + controller.state.value.status == + "Connection lost. Retrying." + } + compose.onNodeWithText("Reconnecting…").assertIsDisplayed() + compose.onNodeWithText("Android smoke workspace").assertIsDisplayed() + screenshots("connection-reconnecting") + compose.onNodeWithText("Saved connections").performClick() + compose.onNodeWithText("Return to workspace").performClick() + compose.onNodeWithText("Android smoke workspace").assertIsDisplayed() + assertEquals(admitted, controller.state.value.session) + connectionState(SessionState.READY) + compose.waitUntil(TIMEOUT) { controller.state.value.status == "Connected" } + compose.onNodeWithText("Reconnecting…").assertDoesNotExist() } } @@ -124,7 +149,11 @@ class CoreFlowsTest { content { NoteReaderScreen( NoteReaderState( - Note("spec", "Smoke plan", content = "Read the project plan."), + Note( + "spec", + "Smoke plan", + content = "Read the project plan.\n\n- [ ] Check the emulator" + ), listOf(row.value), loading = false ), diff --git a/app/src/androidTest/java/dev/intent/app/smoke/GitChangesTest.kt b/app/src/androidTest/java/dev/intent/app/smoke/GitChangesTest.kt index 5a6c431..ba013af 100644 --- a/app/src/androidTest/java/dev/intent/app/smoke/GitChangesTest.kt +++ b/app/src/androidTest/java/dev/intent/app/smoke/GitChangesTest.kt @@ -9,6 +9,8 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollToNode +import androidx.compose.ui.test.performTextClearance +import androidx.compose.ui.test.performTextInput import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import dev.intent.app.design.IntentTheme @@ -95,8 +97,22 @@ class GitChangesTest { waitFor("Commit changes") waitFor("+new line") compose.onNodeWithText("File (2)").assertIsDisplayed() + compose.onNodeWithText("Not pushed").assertIsDisplayed() + compose.onNodeWithTag("commit.copy").performClick() + compose.onNodeWithText("Copied commit hash").assertIsDisplayed() + compose.runOnIdle { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val clipboard = context.getSystemService(android.content.ClipboardManager::class.java) + assertEquals(PRIMARY_HASH, clipboard.primaryClip?.getItemAt(0)?.text.toString()) + } screenshot("changes-commit-diff") compose.onNodeWithText("src/primary.kt").performClick() + compose.onNodeWithText("Search changed files").performTextInput("missing") + compose.onNodeWithText("No matching files").assertIsDisplayed() + compose.onNodeWithText("Search changed files").performTextClearance() + compose.onNodeWithText("Search changed files").performTextInput("OTHER") + compose.onNodeWithText("src/primary.kt (+1 −1)").assertDoesNotExist() + screenshot("changes-commit-file-search") compose.onNodeWithText("other.kt (+1 −0)").performClick() waitFor("+other content") compose.onNodeWithContentDescription("Back").performClick() @@ -180,7 +196,8 @@ private class ChangesServer(private val listener: WssTransport.Listener) : Sessi {"path":"src/staged.kt","status":"M","staged":true}]}""" "file-tracking.loadCommits" -> """{"commits":[ - {"hash":"primary","message":"Primary commit"}]}""" + {"hash":"$PRIMARY_HASH","message":"Primary commit", + "date":"2026-11-04T12:30:00Z","author":"Alex","isPushed":false}]}""" "git.commits" -> """{"items":[{"hash":"secondary","message":"Secondary commit"}]}""" "git.diffs" -> "[]" "git.commitDetails" -> @@ -199,3 +216,5 @@ private class ChangesServer(private val listener: WssTransport.Listener) : Sessi return true } } + +private const val PRIMARY_HASH = "0123456789abcdef0123456789abcdef01234567" diff --git a/app/src/androidTest/java/dev/intent/app/smoke/IntentLauncherTest.kt b/app/src/androidTest/java/dev/intent/app/smoke/IntentLauncherTest.kt new file mode 100644 index 0000000..39b4356 --- /dev/null +++ b/app/src/androidTest/java/dev/intent/app/smoke/IntentLauncherTest.kt @@ -0,0 +1,125 @@ +package dev.intent.app.smoke + +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollToIndex +import androidx.test.platform.app.InstrumentationRegistry +import dev.intent.app.navigation.LauncherActions +import dev.intent.app.navigation.LocalLauncherActions +import dev.intent.app.settings.Appearance +import dev.intent.app.testing.IntentTestTheme +import dev.intent.app.testing.createIntentComposeRule +import dev.intent.app.workspaces.WorkspaceListScreen +import dev.intent.app.workspaces.WorkspaceListState +import dev.intent.core.protocol.models.Workspace +import java.io.File +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +class IntentLauncherTest { + @get:Rule val compose = createIntentComposeRule() + + @Test + fun openingAndClosingReverseContinuously() { + content() + compose.mainClock.autoAdvance = false + screenshot("intent-00-closed") + compose.onNodeWithContentDescription("Open app launcher").performClick() + repeat(6) { + compose.mainClock.advanceTimeBy(48) + screenshot("intent-0${it + 1}-opening") + } + compose.mainClock.advanceTimeBy(1_000) + compose.onNodeWithContentDescription( + "Close app launcher" + ).assertIsDisplayed().performClick() + compose.mainClock.advanceTimeBy(80) + screenshot("intent-07-reversing") + compose.onNodeWithContentDescription("Open app launcher").performClick() + compose.mainClock.advanceTimeBy(1_000) + compose.onNodeWithContentDescription("Close app launcher").assertIsDisplayed() + screenshot("intent-08-open") + compose.onNodeWithContentDescription("Close app launcher").performClick() + repeat(6) { + compose.mainClock.advanceTimeBy(48) + screenshot("intent-${it + 10}-closing") + } + compose.mainClock.advanceTimeBy(1_000) + compose.onNodeWithContentDescription("Open app launcher").assertIsDisplayed() + compose.onNodeWithContentDescription("Close app launcher").assertDoesNotExist() + screenshot("intent-16-closed") + compose.mainClock.autoAdvance = true + } + + @Test + fun appearanceStaysOpenAndSettingsRunsOnceAfterScrimDismissal() { + var settings = 0 + content { settings++ } + compose.onNodeWithContentDescription("Open app launcher").performClick() + compose.onNodeWithText("Appearance: Light").performClick() + compose.onNodeWithText("Appearance: Dark").assertIsDisplayed() + screenshot("intent-dark-open") + compose.onNodeWithContentDescription("Dismiss app launcher").performClick() + compose.onNodeWithContentDescription("Open app launcher").assertIsDisplayed() + compose.onNodeWithContentDescription("Open app launcher").performClick() + compose.onNodeWithTag("launcher.tiles").performScrollToIndex(2) + compose.onNodeWithText("Settings").performClick() + compose.onNodeWithContentDescription("Open app launcher").assertIsDisplayed() + compose.runOnIdle { assertEquals(1, settings) } + } + + private fun content(settings: () -> Unit = {}) { + compose.setContent { + var dark by remember { mutableStateOf(false) } + IntentTestTheme(dark) { + CompositionLocalProvider( + LocalLauncherActions provides LauncherActions( + 1, + if (dark) Appearance.Dark else Appearance.Light, + { dark = !dark }, + {}, + settings + ) + ) { + WorkspaceListScreen( + WorkspaceListState( + listOf( + Workspace( + "workspace", + "Intent menu preview", + statusMessage = "Description stays in workspace details." + ) + ), + false + ), + {}, + {} + ) + } + } + } + } + + private fun screenshot(name: String) { + compose.waitForIdle() + val instrumentation = InstrumentationRegistry.getInstrumentation() + instrumentation.uiAutomation.waitForIdle(100, 5_000) + val root = InstrumentationRegistry.getArguments().getString("additionalTestOutputDir") + ?: File(instrumentation.targetContext.cacheDir, "screenshots").path + val directory = File(root).apply { mkdirs() } + val image = checkNotNull(instrumentation.uiAutomation.takeScreenshot()) + File(directory, "$name.png").outputStream().use { + image.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, it) + } + image.recycle() + } +} diff --git a/app/src/androidTest/java/dev/intent/app/smoke/ListFiltersTest.kt b/app/src/androidTest/java/dev/intent/app/smoke/ListFiltersTest.kt index 4180e6e..2f2ef54 100644 --- a/app/src/androidTest/java/dev/intent/app/smoke/ListFiltersTest.kt +++ b/app/src/androidTest/java/dev/intent/app/smoke/ListFiltersTest.kt @@ -45,7 +45,7 @@ class ListFiltersTest { ) } } - compose.onNodeWithText("▾ Hide 2 delegated agents").performClick() + compose.onNodeWithText("▸ 2 delegated · 1 running").assertIsDisplayed() compose.onNodeWithText("Fix search").assertDoesNotExist() compose.waitUntil(5_000) { compose.onAllNodes(androidx.compose.ui.test.hasContentDescription("Search agents")) @@ -66,6 +66,7 @@ class ListFiltersTest { compose.onNodeWithText("Old work").assertDoesNotExist() compose.onNodeWithText("Fix search").assertIsDisplayed() compose.onNodeWithText("All").performClick() + compose.onNodeWithText("▸ 2 delegated · 1 running").performClick() compose.onNodeWithText("Old work").assertIsDisplayed() } diff --git a/app/src/androidTest/java/dev/intent/app/smoke/ListSpaceTest.kt b/app/src/androidTest/java/dev/intent/app/smoke/ListSpaceTest.kt index 130f924..59d5de4 100644 --- a/app/src/androidTest/java/dev/intent/app/smoke/ListSpaceTest.kt +++ b/app/src/androidTest/java/dev/intent/app/smoke/ListSpaceTest.kt @@ -4,12 +4,16 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsSelected import androidx.compose.ui.test.hasScrollAction +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.hasText import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollToIndex import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipe import androidx.compose.ui.test.swipeUp import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry @@ -23,6 +27,7 @@ import dev.intent.app.navigation.WorkspaceDestination import dev.intent.app.notes.NoteListScreen import dev.intent.app.notes.NoteListState import dev.intent.app.settings.Appearance +import dev.intent.app.testing.IntentTestTheme import dev.intent.app.testing.createIntentComposeRule import dev.intent.app.workspaces.WorkspaceDetailScreen import dev.intent.app.workspaces.WorkspaceListScreen @@ -40,9 +45,9 @@ class ListSpaceTest { @get:Rule val compose = createIntentComposeRule() @Test - fun workspaceLastRowClearsBothFloatingButtons() { + fun workspaceLastRowClearsLauncherAndSearchPreservesGrouping() { compose.setContent { - IntentTheme { + IntentTestTheme { CompositionLocalProvider( LocalLauncherActions provides LauncherActions(1, Appearance.Dark, {}, {}, {}) ) { @@ -59,10 +64,34 @@ class ListSpaceTest { } } } + screenshot("workspace-grouping-top") + compose.onNode(hasScrollAction()).performScrollToIndex(4) + compose.onNode(hasScrollAction()).performTouchInput { + swipe( + androidx.compose.ui.geometry.Offset(centerX, height * 0.6f), + androidx.compose.ui.geometry.Offset(centerX, height * 0.6f - 144f), + durationMillis = 1_000 + ) + } + screenshot("workspace-grouping-fade") + compose.onNodeWithText("Repo").performClick() + compose.onNodeWithContentDescription("Search workspaces").performClick() + compose.onNodeWithText("Search workspaces").performTextInput("Workspace 20") + compose.onNode(hasText("Workspace 20") and !hasSetTextAction()).assertIsDisplayed() + screenshot("workspace-grouping-search") + compose.onNodeWithContentDescription("Close search").performClick() + compose.onNodeWithText("Repo").assertIsSelected() + compose.onNodeWithText("Recent").performClick() scrollToEnd() - assertAboveButton("Workspace 20", "Search workspaces") assertAboveButton("Workspace 20", "Open app launcher") screenshot("workspace-button-clearance") + compose.onNodeWithContentDescription("Open app launcher").performClick() + compose.onNodeWithText("Connections: 1").assertIsDisplayed() + screenshot("workspace-intent-launcher") + compose.onNodeWithTag("launcher.tiles").performScrollToIndex(2) + compose.onNodeWithText("Settings").assertIsDisplayed() + compose.onNodeWithContentDescription("Close app launcher").performClick() + compose.onNodeWithText("Workspace 20").assertIsDisplayed() } @Test diff --git a/app/src/androidTest/java/dev/intent/app/smoke/QuestionControlsTest.kt b/app/src/androidTest/java/dev/intent/app/smoke/QuestionControlsTest.kt new file mode 100644 index 0000000..72ab29c --- /dev/null +++ b/app/src/androidTest/java/dev/intent/app/smoke/QuestionControlsTest.kt @@ -0,0 +1,168 @@ +package dev.intent.app.smoke + +import android.graphics.Bitmap +import androidx.compose.material3.Surface +import androidx.compose.runtime.mutableStateOf +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.assertIsNotSelected +import androidx.compose.ui.test.assertIsOff +import androidx.compose.ui.test.assertIsOn +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTextReplacement +import androidx.test.platform.app.InstrumentationRegistry +import dev.intent.app.conversation.ConversationComposer +import dev.intent.app.conversation.ConversationSendState +import dev.intent.app.conversation.QuestionAnswer +import dev.intent.app.conversation.QuestionSet +import dev.intent.app.design.IntentTheme +import dev.intent.app.testing.createIntentComposeRule +import dev.intent.core.protocol.models.Question +import dev.intent.core.protocol.models.QuestionOption +import java.io.File +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +class QuestionControlsTest { + @get:Rule val compose = createIntentComposeRule() + + @Test + fun pagesKeepAnswersAndWholeRowsUseRadioOrCheckboxSemantics() { + var submitted = emptyMap() + val dark = mutableStateOf(false) + compose.setContent { + IntentTheme(darkTheme = dark.value) { + Surface { + ConversationComposer( + ConversationSendState(questions = questions()), + { false }, + {}, + { id, answers -> + assertEquals("questions", id) + submitted = answers + true + } + ) + } + } + } + compose.onNodeWithText("Which platform?").assertExists() + compose.onNodeWithText("Which checks?").assertDoesNotExist() + compose.onNodeWithText("Android").performScrollTo().performClick() + .assertIsSelected() + .assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.RadioButton)) + compose.onNodeWithText("iOS").performScrollTo().performClick().assertIsSelected() + compose.onNodeWithText("Android").assertIsNotSelected() + compose.onNodeWithText("iOS").performClick().assertIsSelected() + screenshot("question-single-choice-light") + compose.onNodeWithText("Question 1 of 3 · Hide").performClick() + compose.onNodeWithText("Which platform?").assertDoesNotExist() + screenshot("questions-collapsed-light") + compose.onNodeWithText("Questions · 3 · Show").performClick() + compose.onNodeWithText("iOS").assertIsSelected() + compose.onNodeWithText("Next").performClick() + compose.onNodeWithText("Which platform?").assertDoesNotExist() + compose.onNodeWithText("Unit tests").performScrollTo().performClick().assertIsOn() + .assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Checkbox)) + compose.onNodeWithText("Device tests").performScrollTo().performClick().assertIsOn() + compose.runOnIdle { dark.value = true } + screenshot("question-multi-choice-dark") + compose.onNodeWithText("Unit tests").performClick().assertIsOff() + compose.onNodeWithText("Other").performScrollTo().performTextReplacement("Manual review") + compose.onNodeWithText("Back").performClick() + compose.onNodeWithText("iOS").assertIsSelected() + compose.onNodeWithText("Next").performClick() + compose.onNodeWithText("Device tests").assertIsOn() + compose.onNodeWithText("Manual review").assertExists() + compose.onNodeWithText("Next").performClick() + compose.onNodeWithText("Skip question").performClick() + compose.onNodeWithText("Send answers").performClick() + compose.runOnIdle { + assertEquals(listOf("iOS"), submitted.getValue("platform").selected) + assertEquals(listOf("Device tests"), submitted.getValue("checks").selected) + assertEquals("Manual review", submitted.getValue("checks").other) + assertEquals(QuestionAnswer(skipped = true), submitted.getValue("timing")) + } + } + + @Test + fun skippingAdvancesAndReturningAllowsAnAnswer() { + var submitted = emptyMap() + compose.setContent { + IntentTheme { + ConversationComposer( + ConversationSendState( + questions = questions().copy(questions = questions().questions.take(2)) + ), + { false }, + {}, + { _, answers -> + submitted = answers + true + } + ) + } + } + compose.onNodeWithText("Skip question").performClick() + compose.onNodeWithText("Question 2 of 2 · Hide").assertExists() + compose.onNodeWithText("Back").performClick() + compose.onNodeWithText("Skipped").assertExists() + compose.onNodeWithText("Android").performScrollTo().performClick() + compose.onNodeWithText("Next").performClick() + compose.onNodeWithText("Send answers").performClick() + compose.runOnIdle { + assertEquals( + QuestionAnswer(selected = listOf("Android")), + submitted.getValue("platform") + ) + } + } + + private fun screenshot(name: String) { + compose.waitForIdle() + val instrumentation = InstrumentationRegistry.getInstrumentation() + val directory = InstrumentationRegistry.getArguments().getString("additionalTestOutputDir") + ?.let(::File) + ?: File( + instrumentation.targetContext.externalMediaDirs.first(), + "additional_test_output" + ) + check(directory.mkdirs() || directory.isDirectory) + val bitmap = checkNotNull(instrumentation.uiAutomation.takeScreenshot()) + File(directory, "$name.png").outputStream().use { + check(bitmap.compress(Bitmap.CompressFormat.PNG, 100, it)) + } + bitmap.recycle() + } + + private fun questions() = QuestionSet( + "questions", + listOf( + Question( + "platform", + "Platform", + "Which platform?", + listOf(QuestionOption("Android"), QuestionOption("iOS")) + ), + Question( + "checks", + "Checks", + "Which checks?", + listOf(QuestionOption("Unit tests"), QuestionOption("Device tests")), + multiSelect = true + ), + Question( + "timing", + "Timing", + "When?", + listOf(QuestionOption("Today"), QuestionOption("Tomorrow")) + ) + ) + ) +} diff --git a/app/src/androidTest/java/dev/intent/app/smoke/SettingsActionsTest.kt b/app/src/androidTest/java/dev/intent/app/smoke/SettingsActionsTest.kt new file mode 100644 index 0000000..46c3b80 --- /dev/null +++ b/app/src/androidTest/java/dev/intent/app/smoke/SettingsActionsTest.kt @@ -0,0 +1,116 @@ +package dev.intent.app.smoke + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Surface +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performScrollToIndex +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import dev.intent.app.R +import dev.intent.app.connection.ConnectionDiagnostics +import dev.intent.app.connection.ConnectionSettings +import dev.intent.app.connection.ConnectionState +import dev.intent.app.connection.SavedConnection +import dev.intent.app.design.IntentTheme +import dev.intent.app.settings.AppPreferences +import dev.intent.app.testing.createIntentComposeRule +import dev.intent.core.protocol.pairing.PairingPayload +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class SettingsActionsTest { + @get:Rule val compose = createIntentComposeRule() + + @Test + fun pairingManagementAndDisconnectCallDistinctActions() { + var paired = 0 + var managed = 0 + var disconnected = 0 + val context = InstrumentationRegistry.getInstrumentation().targetContext + val state = ConnectionState( + connections = listOf( + SavedConnection("one", PairingPayload(listOf("desktop.local"), 443, "secret")) + ), + activeId = "one" + ) + compose.setContent { + IntentTheme(darkTheme = false) { + Surface(Modifier.fillMaxSize()) { + ConnectionSettings( + diagnostics = ConnectionDiagnostics(), + preferences = AppPreferences(context), + state = state, + onDisconnect = { disconnected++ }, + onPair = { paired++ }, + onConnections = { managed++ } + ) + } + } + } + compose.onNodeWithText("Pair computer").performScrollTo() + screenshot("settings-pairing-light") + compose.onNodeWithText("Pair computer").performClick() + compose.runOnIdle { + assertEquals(1, paired) + assertEquals(0, managed) + assertEquals(0, disconnected) + } + compose.onNodeWithText("Manage connections").performScrollTo().performClick() + compose.onNodeWithText("Disconnect").performScrollTo().performClick() + compose.runOnIdle { + assertEquals(1, managed) + assertEquals(1, disconnected) + assertEquals(1, state.connections.size) + } + } + + @Test + fun acknowledgementsReadBundledLicenseAndDismissBackToSettings() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val mermaidNotice = context.assets.open("mermaid/NOTICE.txt") + .bufferedReader().use { it.readText() } + val interLicense = context.resources.openRawResource(R.raw.inter_license) + .bufferedReader().use { it.readText() } + compose.setContent { + IntentTheme(darkTheme = false) { + Surface(Modifier.fillMaxSize()) { + ConnectionSettings( + diagnostics = ConnectionDiagnostics(), + preferences = AppPreferences(context), + onConnections = {} + ) + } + } + } + compose.onNodeWithText("Acknowledgements").performScrollTo().performClick() + compose.onNodeWithText(interLicense).assertIsDisplayed() + screenshot("settings-acknowledgements-light") + compose.onNodeWithTag("settings.acknowledgements").performScrollToIndex(2) + compose.onNodeWithText(mermaidNotice).assertIsDisplayed() + compose.onNodeWithText("Close acknowledgements").performClick() + compose.onNodeWithText("Close acknowledgements").assertDoesNotExist() + compose.onNodeWithText("Acknowledgements").assertIsDisplayed() + } + + private fun screenshot(name: String) { + compose.waitForIdle() + val instrumentation = InstrumentationRegistry.getInstrumentation() + val directory = InstrumentationRegistry.getArguments().getString("additionalTestOutputDir") + ?.let { java.io.File(it) } + ?: java.io.File(instrumentation.targetContext.cacheDir, "screenshots") + check(directory.mkdirs() || directory.isDirectory) + val bitmap = checkNotNull(instrumentation.uiAutomation.takeScreenshot()) + java.io.File(directory, "$name.png").outputStream().use { + check(bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, it)) + } + bitmap.recycle() + } +} diff --git a/app/src/androidTest/java/dev/intent/app/smoke/TerminalLifecycleTest.kt b/app/src/androidTest/java/dev/intent/app/smoke/TerminalLifecycleTest.kt index 64c942a..1ba35b7 100644 --- a/app/src/androidTest/java/dev/intent/app/smoke/TerminalLifecycleTest.kt +++ b/app/src/androidTest/java/dev/intent/app/smoke/TerminalLifecycleTest.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextReplacement import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import dev.intent.app.design.IntentTheme @@ -41,6 +42,11 @@ class TerminalLifecycleTest { compose.onNodeWithText("New terminal").performClick() waitText("Keyboard") screenshot("terminal-created") + compose.onNodeWithText("More keys").performClick() + compose.onNodeWithTag("terminal-extra-keys").assertIsDisplayed() + screenshot("terminal-expanded-keys") + compose.onNodeWithText("Less keys").performClick() + compose.onNodeWithTag("terminal-extra-keys").assertDoesNotExist() val create = server.actions.single() assertEquals("terminal.create", create.method) assertEquals( @@ -48,7 +54,13 @@ class TerminalLifecycleTest { create.params ) compose.onNodeWithContentDescription("Back").performClick() + compose.onNodeWithTag("terminal-rename-new").performClick() + compose.onNodeWithTag("terminal-name").performTextReplacement("Build logs") + screenshot("terminal-rename") + compose.onNodeWithText("Rename").performClick() + compose.onNodeWithText("Build logs", substring = true).assertIsDisplayed() compose.onNodeWithTag("terminal-open-new").performClick() + compose.onNodeWithText("Build logs").assertIsDisplayed() waitText("Keyboard") compose.onNodeWithContentDescription("Back").performClick() compose.onNodeWithTag("terminal-delete-new").performClick() diff --git a/app/src/androidTest/java/dev/intent/app/smoke/WorkspaceActionsTest.kt b/app/src/androidTest/java/dev/intent/app/smoke/WorkspaceActionsTest.kt index e639408..8102928 100644 --- a/app/src/androidTest/java/dev/intent/app/smoke/WorkspaceActionsTest.kt +++ b/app/src/androidTest/java/dev/intent/app/smoke/WorkspaceActionsTest.kt @@ -4,6 +4,8 @@ import android.content.Context import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.hasText import androidx.compose.ui.test.longClick +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onFirst import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText @@ -105,7 +107,7 @@ class WorkspaceActionsTest { compose.onNodeWithContentDescription("Back").performClick() compose.onNodeWithTag("workspace.row.workspace").performTouchInput { swipeRight() } waitFor("Message") - compose.onNodeWithText("Recent delegate").assertIsDisplayed() + compose.onAllNodesWithText("Recent delegate").onFirst().assertIsDisplayed() assertTrue( server.requests.any { it.method == "agent.getConversation" && it.params.toString().contains("recent") diff --git a/app/src/androidTest/java/dev/intent/app/smoke/WorkspaceCreateTest.kt b/app/src/androidTest/java/dev/intent/app/smoke/WorkspaceCreateTest.kt index 37bce1c..bb9f44e 100644 --- a/app/src/androidTest/java/dev/intent/app/smoke/WorkspaceCreateTest.kt +++ b/app/src/androidTest/java/dev/intent/app/smoke/WorkspaceCreateTest.kt @@ -39,9 +39,9 @@ class WorkspaceCreateTest { val workflow = CreationWorkflow(fake) var created = "" compose.setContent { IntentTheme { WorkspaceCreateScreen(workflow, {}, { created = it }) } } - waitText("intent-hq/android") + waitText("android") screenshot("creation-repositories") - compose.onNodeWithText("intent-hq/android").performClick() + compose.onNodeWithText("android").performClick() tap("Choose branch") waitText("main") screenshot("creation-branches") @@ -99,6 +99,30 @@ class WorkspaceCreateTest { assertEquals("a", persistence.load().provider) } + @Test fun noSpecialistSurvivesReloadAndIsOmittedFromCreation() { + val fake = CreateChoices() + val workflow = CreationWorkflow(fake, WorkspaceDraft(step = CreationStep.AGENT)) + compose.setContent { IntentTheme { WorkspaceCreateScreen(workflow, {}, {}) } } + waitText("Provider A") + tap("Specialist B") + tap("None") + tap("Previous step") + tap("Previous step") + tap("New repository") + compose.onNodeWithText("Repository name").performTextInput("plain-agent") + tap("Configure agent") + waitText("Provider A") + assertEquals("", workflow.draft.value.specialist) + assertEquals( + 0, + compose.onAllNodesWithText("Retry agent choices").fetchSemanticsNodes().size + ) + screenshot("creation-no-specialist") + tap("Create workspace") + compose.waitUntil { fake.created != null } + assertEquals(null, (fake.created!!["initialAgent"] as JsonObject)["specialist"]) + } + @Test fun mediaPromptStagesBeforeCreateAndSendsAfterPlacement() { val context = InstrumentationRegistry.getInstrumentation().targetContext val file = File(context.cacheDir, "creation-test.txt").apply { writeText("test") } diff --git a/app/src/androidTest/java/dev/intent/app/smoke/WorkspaceDetailTest.kt b/app/src/androidTest/java/dev/intent/app/smoke/WorkspaceDetailTest.kt index 20e0dbc..e0c5d9c 100644 --- a/app/src/androidTest/java/dev/intent/app/smoke/WorkspaceDetailTest.kt +++ b/app/src/androidTest/java/dev/intent/app/smoke/WorkspaceDetailTest.kt @@ -1,19 +1,26 @@ package dev.intent.app.smoke -import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.hasAnyAncestor import androidx.compose.ui.test.hasScrollAction import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.isDialog +import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollToNode +import androidx.compose.ui.test.performTextInput import androidx.test.ext.junit.runners.AndroidJUnit4 -import dev.intent.app.design.IntentTheme +import androidx.test.platform.app.InstrumentationRegistry import dev.intent.app.navigation.WorkspaceDestination import dev.intent.app.notes.NoteReaderScreen import dev.intent.app.notes.NoteReaderState +import dev.intent.app.testing.IntentTestTheme import dev.intent.app.testing.createIntentComposeRule import dev.intent.app.workspaces.WorkspaceDetailScreen +import dev.intent.app.workspaces.WorkspaceListScreen +import dev.intent.app.workspaces.WorkspaceListState import dev.intent.core.protocol.models.Note import dev.intent.core.protocol.models.Workspace import org.junit.Assert.assertEquals @@ -29,19 +36,22 @@ class WorkspaceDetailTest { fun summaryStateAndToolsAreReachable() { var selected: WorkspaceDestination? = null compose.setContent { - IntentTheme { + IntentTestTheme { WorkspaceDetailScreen( Workspace( "work", "Workspace", statusMessage = "Ready for review", - displayStatus = "pr_ready" + displayStatus = "pr_ready", + repositoryOwner = "intent-hq", + repositoryName = "android" ) ) { selected = it } } } compose.onNodeWithText("Ready for review").assertIsDisplayed() - compose.onNodeWithText("Active · PR ready").assertIsDisplayed() + compose.onNodeWithText("Active · PR mergeable").assertIsDisplayed() + compose.onNodeWithText("intent-hq/android").assertIsDisplayed() listOf( "Agents" to WorkspaceDestination.Conversations, "Context" to WorkspaceDestination.Notes, @@ -50,38 +60,98 @@ class WorkspaceDetailTest { compose.onNodeWithText(title).performClick() compose.runOnIdle { assertEquals(destination, selected) } } + screenshot("workspace-detail-hierarchy") } @Test - fun noteHeaderKeepsItsPositionWhileContentScrolls() { - val detail = mutableStateOf(true) + fun detailsExposeFullSummaryAndIndependentStatusSignals() { + val summary = + "The repository search and grouping controls are ready. " + + "Review the archived-workspace filter, the repository owner labels, " + + "and the waiting indicator before approving this change." compose.setContent { - IntentTheme { - if (detail.value) { - WorkspaceDetailScreen(Workspace("work", "Same title")) {} - } else { - NoteReaderScreen( - NoteReaderState( - note = Note( - "note", - "Same title", - content = (1..60).joinToString("\n\n") { - "Paragraph $it" - } + IntentTestTheme { + WorkspaceDetailScreen( + Workspace( + "work", + "Workspace", + statusMessage = summary, + displayStatus = "pr_queued", + prNumber = 42, + prStatus = "Open", + attention = "unread", + waiting = true + ) + ) {} + } + } + compose.onNodeWithText("Workspace details").performClick() + listOf(summary, "PR #42 · Open", "Unread updates", "Waiting").forEach { + compose.onNode(hasText(it) and hasAnyAncestor(isDialog())).assertIsDisplayed() + } + screenshot("workspace-full-summary") + compose.onNodeWithText("Done").performClick() + compose.onNodeWithText("Agents").assertIsDisplayed() + } + + @Test + fun groupingAndRepositorySearchKeepWorkspaceSelection() { + var selected: String? = null + compose.setContent { + IntentTestTheme { + WorkspaceListScreen( + WorkspaceListState( + listOf( + Workspace( + "work", + "Improve search", + repositoryOwner = "intent-hq", + repositoryName = "android", + displayStatus = "in_progress", + activity = "agent_running", + attention = "unread", + prNumber = 42 ), - loading = false + Workspace("other", "Other work", repositoryName = "ios") ), - {} - ) - } + isLoading = false + ), + onRefresh = {}, + onWorkspaceSelected = { selected = it } + ) + } + } + compose.onNodeWithText("Repo").performClick().assertIsSelected() + screenshot("workspace-repository-groups") + compose.onNodeWithText("Status").performClick().assertIsSelected() + screenshot("workspace-status-groups") + compose.onNodeWithContentDescription("Search workspaces").performClick() + compose.onNodeWithText("Search workspaces").performTextInput("intent-hq/android") + compose.onNodeWithText( + "Improve search", + substring = true + ).assertIsDisplayed().performClick() + compose.runOnIdle { assertEquals("work", selected) } + } + + @Test + fun noteHeaderKeepsItsPositionWhileContentScrolls() { + compose.setContent { + IntentTestTheme { + NoteReaderScreen( + NoteReaderState( + note = Note( + "note", + "Same title", + content = (1..60).joinToString("\n\n") { "Paragraph $it" } + ), + loading = false + ), + {} + ) } } val bounds = compose.onNodeWithText("Same title").fetchSemanticsNode().boundsInRoot.topLeft - compose.runOnIdle { detail.value = false } - assertEquals( - bounds, - compose.onNodeWithText("Same title").fetchSemanticsNode().boundsInRoot.topLeft - ) compose.onNode(hasScrollAction()).performScrollToNode(hasText("Paragraph 60")) compose.onNodeWithText("Paragraph 60").assertIsDisplayed() assertEquals( @@ -89,4 +159,21 @@ class WorkspaceDetailTest { compose.onNodeWithText("Same title").fetchSemanticsNode().boundsInRoot.topLeft ) } + + private fun screenshot(name: String) { + compose.waitForIdle() + val instrumentation = InstrumentationRegistry.getInstrumentation() + val directory = + InstrumentationRegistry + .getArguments() + .getString("additionalTestOutputDir") + ?.let { java.io.File(it) } + ?: java.io.File(instrumentation.targetContext.cacheDir, "screenshots") + check(directory.mkdirs() || directory.isDirectory) + val bitmap = checkNotNull(instrumentation.uiAutomation.takeScreenshot()) + java.io.File(directory, "$name.png").outputStream().use { + bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, it) + } + bitmap.recycle() + } } diff --git a/app/src/androidTest/java/dev/intent/app/smoke/WorkspaceDrawersTest.kt b/app/src/androidTest/java/dev/intent/app/smoke/WorkspaceDrawersTest.kt index a94d1e5..e629d02 100644 --- a/app/src/androidTest/java/dev/intent/app/smoke/WorkspaceDrawersTest.kt +++ b/app/src/androidTest/java/dev/intent/app/smoke/WorkspaceDrawersTest.kt @@ -3,7 +3,11 @@ package dev.intent.app.smoke import android.content.Context import androidx.activity.BackEventCompat import androidx.activity.OnBackPressedDispatcher +import androidx.activity.compose.LocalActivity import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsSelected @@ -17,6 +21,7 @@ import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.swipe import androidx.compose.ui.test.swipeLeft import androidx.compose.ui.test.swipeRight +import androidx.core.view.WindowCompat import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import dev.intent.app.connection.ConnectionController @@ -24,9 +29,9 @@ import dev.intent.app.connection.ConnectionRepository import dev.intent.app.connection.ConnectionRoot import dev.intent.app.connection.SavedConnection import dev.intent.app.connection.createConnectionSession -import dev.intent.app.design.IntentTheme import dev.intent.app.history.AgentHistoryStore import dev.intent.app.history.AgentVisit +import dev.intent.app.testing.IntentTestTheme import dev.intent.app.testing.createIntentComposeRule import dev.intent.core.protocol.pairing.PairingPayload import org.junit.Assert.assertEquals @@ -42,7 +47,29 @@ class WorkspaceDrawersTest { private val servers = java.util.concurrent.ConcurrentHashMap() @Test - fun contentSwipesOpenAndCloseBothDrawers() = connected { + fun nativeEdgeSwipesOpenBothPanelsWithSystemNavigationEnabled() = connected { + val bounds = compose.onNodeWithTag("workspace.drawers").fetchSemanticsNode().boundsInRoot + val middle = bounds.center.y.toInt() + val instrumentation = InstrumentationRegistry.getInstrumentation() + fun swipe(from: Int, to: Int) { + compose.waitForIdle() + val output = instrumentation.uiAutomation.executeShellCommand( + "input swipe $from $middle $to $middle 350" + ) + android.os.ParcelFileDescriptor.AutoCloseInputStream(output).use { it.readBytes() } + compose.waitForIdle() + } + swipe(bounds.left.toInt() + 1, (bounds.left + bounds.width * 0.9f).toInt()) + compose.onNodeWithTag("drawer.Connections").assertIsDisplayed() + compose.runOnIdle { dispatcher.onBackPressed() } + compose.onNodeWithTag("drawer.Connections").assertDoesNotExist() + swipe(bounds.right.toInt() - 1, (bounds.left + bounds.width * 0.1f).toInt()) + compose.onNodeWithTag("drawer.Agent history").assertIsDisplayed() + } + + @Test + fun edgeSwipesOpenAndCloseBothDrawers() = connected { + screenshot("workspace-edge-navigation-light") contentSwipe(right = true) compose.onNodeWithTag("drawer.Connections").assertIsDisplayed() screenshot("connections-drawer") @@ -57,12 +84,27 @@ class WorkspaceDrawersTest { } @Test - fun buttonsBackAndPredictiveBackKeepOnlyOneDrawerOpen() = connected { - compose.onNodeWithContentDescription("Connections").performClick() + fun headerSwipesAwayFromEdgesOpenBothDrawers() = connected { + val header = compose.onNodeWithText("Workspaces", substring = false) + header.performTouchInput { + swipe(Offset(width * 0.2f, centerY), Offset(width * 0.95f, centerY), 300) + } compose.onNodeWithTag("drawer.Connections").assertIsDisplayed() compose.runOnIdle { dispatcher.onBackPressed() } compose.onNodeWithTag("drawer.Connections").assertDoesNotExist() - compose.onNodeWithContentDescription("Agent history").performClick() + header.performTouchInput { + swipe(Offset(width * 0.9f, centerY), Offset(width * 0.1f, centerY), 300) + } + compose.onNodeWithTag("drawer.Agent history").assertIsDisplayed() + } + + @Test + fun edgeSwipesBackAndPredictiveBackKeepOnlyOneDrawerOpen() = connected { + contentSwipe(right = true) + compose.onNodeWithTag("drawer.Connections").assertIsDisplayed() + compose.runOnIdle { dispatcher.onBackPressed() } + compose.onNodeWithTag("drawer.Connections").assertDoesNotExist() + contentSwipe(right = false) compose.onNodeWithTag("drawer.Agent history").assertIsDisplayed() compose.onNodeWithTag("drawer.Connections").assertDoesNotExist() compose.runOnIdle { @@ -98,7 +140,7 @@ class WorkspaceDrawersTest { compose.onNodeWithContentDescription("Back").performClick() waitFor("Other agent") compose.onNodeWithContentDescription("Back").performClick() - compose.onNodeWithContentDescription("Agent history").performClick() + contentSwipe(right = false) waitForHistory("history.first.agent", selected = false) compose.onNodeWithTag("history.first.agent").assertIsDisplayed().performClick() waitForConversation() @@ -108,11 +150,12 @@ class WorkspaceDrawersTest { @Test fun historySwitchesConnectionAndOpensExactConversation() = connected(seed = true) { controller -> - compose.onNodeWithContentDescription("Agent history").performClick() + contentSwipe(right = false) waitForHistory("history.second.agent", selected = false) compose.onNodeWithTag("history.second.agent").assertIsDisplayed() val name = compose.onNodeWithText( "Remote agent", + substring = true, useUnmergedTree = true ).fetchSemanticsNode().boundsInRoot val context = compose.onNodeWithText( @@ -120,7 +163,7 @@ class WorkspaceDrawersTest { substring = true, useUnmergedTree = true ).fetchSemanticsNode().boundsInRoot - assertTrue(name.top < context.top) + assertTrue(context.top < name.top) screenshot("agent-history-drawer") waitForHistory("history.second.agent", selected = false) compose.onNodeWithTag("history.second.agent").performClick() @@ -135,7 +178,7 @@ class WorkspaceDrawersTest { compose.onNodeWithContentDescription("Back").performClick() waitFor("Other agent") compose.onNodeWithContentDescription("Back").performClick() - compose.onNodeWithContentDescription("Connections").performClick() + contentSwipe(right = true) compose.onNodeWithTag("drawer.connection.first").performClick() waitFor("Workspaces") compose.waitUntil(TIMEOUT) { controller.state.value.activeId == "first" } @@ -143,25 +186,25 @@ class WorkspaceDrawersTest { @Test fun clearHistoryAndScrimClose() = connected(seed = true) { - compose.onNodeWithContentDescription("Agent history").performClick() + contentSwipe(right = false) compose.onNodeWithText("Clear").performClick() compose.onNodeWithText("Agents you open on any device appear here.").assertIsDisplayed() compose.onNodeWithContentDescription("Close Agent history").performClick() compose.onNodeWithTag("drawer.Agent history").assertDoesNotExist() - compose.onNodeWithContentDescription("Connections").performClick() + contentSwipe(right = true) compose.onNodeWithTag("drawer.connection.first").performClick() compose.onNodeWithTag("drawer.Connections").assertDoesNotExist() assertEquals(1, servers.size) } @Test - fun swipesStartingAtSystemEdgesDoNotOpenDrawers() = connected { + fun swipesStartingInsideTheListDoNotOpenDrawers() = connected { compose.onNodeWithTag("workspace.drawers").performTouchInput { - swipe(Offset(0f, height * 0.85f), Offset(width * 0.8f, height * 0.85f)) + swipe(Offset(width * 0.2f, height * 0.85f), Offset(width * 0.8f, height * 0.85f)) } compose.onNodeWithTag("drawer.Connections").assertDoesNotExist() compose.onNodeWithTag("workspace.drawers").performTouchInput { - swipe(Offset(width - 1f, height * 0.85f), Offset(width * 0.2f, height * 0.85f)) + swipe(Offset(width * 0.8f, height * 0.85f), Offset(width * 0.2f, height * 0.85f)) } compose.onNodeWithTag("drawer.Agent history").assertDoesNotExist() } @@ -176,7 +219,7 @@ class WorkspaceDrawersTest { compose.onNodeWithTag("drawer.Agent history").assertIsDisplayed() waitForHistory("history.first.agent", selected = true) compose.onNodeWithTag("history.first.agent").assertIsSelected() - compose.onNodeWithText("Current agent").assertIsDisplayed() + compose.onNodeWithText("Current agent", substring = true).assertIsDisplayed() screenshot("conversation-history-light") compose.runOnIdle { dispatcher.onBackPressed() } compose.onNodeWithTag("drawer.Agent history").assertDoesNotExist() @@ -197,7 +240,8 @@ class WorkspaceDrawersTest { @Test fun conversationHistoryAndConnectionsDarkAppearance() = connected(seed = true, dark = true) { - compose.onNodeWithContentDescription("Connections").performClick() + screenshot("workspace-edge-navigation-dark") + contentSwipe(right = true) compose.onNodeWithTag("drawer.connection.first").assertIsSelected() screenshot("connections-drawer-dark") compose.runOnIdle { dispatcher.onBackPressed() } @@ -275,6 +319,7 @@ class WorkspaceDrawersTest { private fun screenshot(name: String) { compose.waitForIdle() val instrumentation = InstrumentationRegistry.getInstrumentation() + instrumentation.uiAutomation.waitForIdle(500, 5_000) val directory = InstrumentationRegistry.getArguments().getString("additionalTestOutputDir") ?.let { java.io.File(it) } ?: java.io.File(instrumentation.targetContext.cacheDir, "screenshots") @@ -288,7 +333,7 @@ class WorkspaceDrawersTest { private fun contentSwipe(right: Boolean) { compose.onNodeWithTag("workspace.drawers").performTouchInput { - val from = if (right) 0.2f else 0.8f + val from = if (right) 0.001f else 0.999f val to = if (right) 0.9f else 0.1f swipe(Offset(width * from, height * 0.85f), Offset(width * to, height * 0.85f)) } @@ -326,7 +371,19 @@ class WorkspaceDrawersTest { controller.restore() compose.setContent { dispatcher = LocalOnBackPressedDispatcherOwner.current!!.onBackPressedDispatcher - IntentTheme(darkTheme = dark) { ConnectionRoot(controller, {}) } + val activity = checkNotNull(LocalActivity.current) + androidx.compose.runtime.SideEffect { + WindowCompat.getInsetsController( + activity.window, + activity.window.decorView + ).apply { + isAppearanceLightStatusBars = !dark + isAppearanceLightNavigationBars = !dark + } + } + IntentTestTheme(darkTheme = dark) { + ConnectionRoot(controller, {}, Modifier.fillMaxSize().safeDrawingPadding()) + } } waitFor("Android smoke workspace") test(controller) diff --git a/app/src/androidTest/java/dev/intent/app/smoke/WorkspaceFilesTest.kt b/app/src/androidTest/java/dev/intent/app/smoke/WorkspaceFilesTest.kt index 5b026ad..18e3e23 100644 --- a/app/src/androidTest/java/dev/intent/app/smoke/WorkspaceFilesTest.kt +++ b/app/src/androidTest/java/dev/intent/app/smoke/WorkspaceFilesTest.kt @@ -58,6 +58,10 @@ class WorkspaceFilesTest { .findAccessibilityNodeInfosByText("Hello from the workspace.") org.junit.Assert.assertTrue(textNodes.any { it.isVisibleToUser }) screenshot("files-text") + compose.onNodeWithText("Line wrap: on").performClick() + compose.onNodeWithText("Line wrap: off").assertIsDisplayed() + screenshot("files-text-nowrap") + compose.onNodeWithText("Line wrap: off").performClick() compose.onNodeWithContentDescription("More actions").assertDoesNotExist() compose.onNodeWithText("Open in another app").assertIsDisplayed() compose.onNodeWithContentDescription("Back").performClick() @@ -87,6 +91,24 @@ class WorkspaceFilesTest { compose.onNodeWithTag("files.file.picture.png").assertIsDisplayed() } + @Test + fun pdfExplainsAccessibleReaderPathAndLabelsPageImages() = connected { + scrollTo("files.file.document.pdf") + compose.onNodeWithTag("files.file.document.pdf").performClick() + waitForTag("file.pdf") + compose.onNodeWithText("Open PDF in another app").assertIsDisplayed() + compose.onNodeWithText( + "This preview shows page images.", + substring = true + ).assertIsDisplayed() + compose.waitUntil(TIMEOUT) { + compose.onAllNodes( + androidx.compose.ui.test.hasContentDescription("PDF page 1, image preview") + ).fetchSemanticsNodes().isNotEmpty() + } + screenshot("files-pdf-reader") + } + @Test fun emptyFoldersAndUnsupportedPreviewHaveClearStates() = connected { scrollTo("files.folder.empty") @@ -196,7 +218,8 @@ private class FilesTestServer(private val listener: WssTransport.Listener) : Ses "file.list" -> when (path) { "." -> """[{"name":"src","type":"directory"},{"name":"empty","type":"directory"}, - {"name":"picture.png","type":"file"},{"name":"archive.zip","type":"file"}]""" + {"name":"picture.png","type":"file"},{"name":"document.pdf","type":"file"}, + {"name":"archive.zip","type":"file"}]""" "src" -> """[{"name":"readme.txt","type":"file"}]""" else -> "[]" } @@ -205,6 +228,8 @@ private class FilesTestServer(private val listener: WssTransport.Listener) : Ses "picture.png" ) { imageBytes() + } else if (path == "document.pdf") { + pdfBytes() } else { "Hello from the workspace.\nText wraps here.".toByteArray() } @@ -224,6 +249,21 @@ private class FilesTestServer(private val listener: WssTransport.Listener) : Ses it.params!!.jsonObject.getValue("path").jsonPrimitive.content } + private fun pdfBytes(): ByteArray = ByteArrayOutputStream().use { output -> + val document = android.graphics.pdf.PdfDocument() + try { + val page = document.startPage( + android.graphics.pdf.PdfDocument.PageInfo.Builder(400, 600, 1).create() + ) + page.canvas.drawText("Accessible reader example", 20f, 40f, android.graphics.Paint()) + document.finishPage(page) + document.writeTo(output) + } finally { + document.close() + } + output.toByteArray() + } + private fun imageBytes(): ByteArray = ByteArrayOutputStream().use { output -> val bitmap = Bitmap.createBitmap(16, 16, Bitmap.Config.ARGB_8888) bitmap.eraseColor(android.graphics.Color.BLUE) diff --git a/app/src/androidTest/java/dev/intent/app/smoke/WorkspaceSectionsTest.kt b/app/src/androidTest/java/dev/intent/app/smoke/WorkspaceSectionsTest.kt index 7141d3b..ae4a458 100644 --- a/app/src/androidTest/java/dev/intent/app/smoke/WorkspaceSectionsTest.kt +++ b/app/src/androidTest/java/dev/intent/app/smoke/WorkspaceSectionsTest.kt @@ -31,7 +31,7 @@ import androidx.test.runner.lifecycle.Stage import dev.intent.app.connection.ConnectionController import dev.intent.app.connection.ConnectionRoot import dev.intent.app.connection.createConnectionSession -import dev.intent.app.design.IntentTheme +import dev.intent.app.testing.IntentTestTheme import dev.intent.app.testing.createIntentComposeRule import dev.intent.core.protocol.pairing.PairingPayload import org.junit.Rule @@ -310,7 +310,7 @@ class WorkspaceSectionsTest { }).use { controller -> controller.restore() compose.setContent { - IntentTheme(dark.value) { + IntentTestTheme(dark.value) { ConnectionRoot( controller, {}, diff --git a/app/src/androidTest/java/dev/intent/app/smoke/WorkspaceTransferTest.kt b/app/src/androidTest/java/dev/intent/app/smoke/WorkspaceTransferTest.kt index c032cbb..bfb68a0 100644 --- a/app/src/androidTest/java/dev/intent/app/smoke/WorkspaceTransferTest.kt +++ b/app/src/androidTest/java/dev/intent/app/smoke/WorkspaceTransferTest.kt @@ -7,9 +7,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertIsSelected import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import dev.intent.app.connection.ConnectionMetadata @@ -110,6 +112,9 @@ class WorkspaceTransferTest { source.failed() waitText("Transfer failed") screenshot("transfer-failed") + compose.onNodeWithText("Review and retry").performClick() + waitText("Review the preview and choose a destination.") + assertEquals(1, source.calls.count { it == "workspace.export.start" }) assertFalse(target.calls.contains("workspace.import.commit")) } } @@ -129,24 +134,69 @@ class WorkspaceTransferTest { "The destination did not confirm import. Check it before retrying. The source was not archived." ) screenshot("transfer-uncertain") + assertEquals( + 0, + compose.onAllNodesWithText("Review and retry").fetchSemanticsNodes().size + ) assertFalse(source.calls.contains("workspace.export.finalize")) assertFalse(target.calls.contains("workspace.import.abort")) assertEquals(1, target.calls.count { it == "workspace.import.commit" }) } } + @Test fun previewRetryDoesNotStartTransfer() { + withTransfer(source = TransferServer(failPreview = true)) { source, _ -> + waitText("Retry preview") + compose.onNodeWithText("Transfer workspace").performScrollTo().assertIsNotEnabled() + source.failPreview = false + compose.onNodeWithText("Retry preview").performScrollTo().performClick() + waitText("Review the preview and choose a destination.") + compose.onNodeWithText("destination").performScrollTo().performClick() + compose.onNodeWithText("Selected destination: destination").assertIsDisplayed() + screenshot("transfer-preview-recovered") + assertFalse(source.calls.contains("workspace.export.start")) + } + } + + @Test fun nonOwnerCannotPreviewOrStartTransfer() { + withTransfer(source = TransferServer(owner = false)) { source, _ -> + waitText("Only a host owner can transfer a workspace. Check your access on the host.") + compose.onNodeWithText("destination").performScrollTo().performClick() + compose.onNodeWithText("Transfer workspace").performScrollTo().assertIsNotEnabled() + assertFalse(source.calls.contains("workspace.transfer.plan")) + assertFalse(source.calls.contains("workspace.export.start")) + } + } + + @Test fun disconnectedSourceCannotPreviewOrStartTransfer() { + withTransfer(sourceState = SessionState.DISCONNECTED) { source, _ -> + waitText("Reconnect to the source host to preview this transfer.") + compose.onNodeWithText("destination").performScrollTo().performClick() + compose.onNodeWithText("Transfer workspace").performScrollTo().assertIsNotEnabled() + assertFalse(source.calls.contains("workspace.transfer.plan")) + assertFalse(source.calls.contains("workspace.export.start")) + } + } + private fun start(source: TransferServer) { - waitText("Transfer to destination") + waitText("Review the preview and choose a destination.") compose.waitUntil(10_000) { compose.onAllNodesWithText("Review the preview and choose a destination.") .fetchSemanticsNodes().isNotEmpty() } - compose.onNodeWithText("Transfer to destination").performClick() + compose.onNodeWithText("Transfer workspace").performScrollTo().assertIsNotEnabled() + compose.onNodeWithText("destination").performScrollTo().performClick() + compose.onNodeWithText("destination").assertIsSelected() + assertFalse(source.calls.contains("workspace.export.start")) + compose.onNodeWithText("Transfer workspace").performScrollTo().performClick() compose.waitUntil(10_000) { source.calls.contains("workspace.export.start") } } - private fun withTransfer(test: (TransferServer, TransferServer) -> Unit) { - val source = TransferServer() + private fun withTransfer( + source: TransferServer = TransferServer(), + sourceState: SessionState = SessionState.READY, + test: (TransferServer, TransferServer) -> Unit + ) { val target = TransferServer(hold = setOf("workspace.import.chunk", "workspace.import.commit")) val ready = AtomicBoolean() @@ -169,6 +219,9 @@ class WorkspaceTransferTest { ) ), onBusy = {}, + workspaceTitle = "Android workspace", + sourceName = "Source Mac", + sourceState = sourceState, createTarget = { _, observer -> RpcSession( target::transport, @@ -192,7 +245,7 @@ class WorkspaceTransferTest { compose.waitUntil(10_000) { compose.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty() } - compose.onNodeWithText(text).assertIsDisplayed() + compose.onNodeWithText(text).performScrollTo().assertIsDisplayed() } private fun screenshot(name: String) { @@ -209,7 +262,11 @@ class WorkspaceTransferTest { } } -private class TransferServer(private val hold: Set = emptySet()) { +private class TransferServer( + private val hold: Set = emptySet(), + private val owner: Boolean = true, + var failPreview: Boolean = false +) { val calls = CopyOnWriteArrayList() val pending = ConcurrentHashMap() private lateinit var listener: WssTransport.Listener @@ -234,12 +291,15 @@ private class TransferServer(private val hold: Set = emptySet()) { private fun reply(request: JsonRpcMessage.Request, result: String? = null) { val response = result ?: when (request.method) { - "principal.me" -> """{"id":"owner","isAdministrator":true}""" + "principal.me" -> """{"id":"person","isAdministrator":$owner}""" "system.status" -> """{"version":"1"}""" "workspace.list" -> """{"workspaces":[]}""" "events.subscribe" -> """{"subscriptionId":"transfer"}""" - "workspace.transfer.plan" -> + "workspace.transfer.plan" -> if (failPreview) { + "{}" + } else { """{"plan":{"manifest":$MANIFEST,"totalSizeBytes":2,"warnings":[]}}""" + } "workspace.export.start" -> """{"exportId":"export"}""" "workspace.import.begin" -> """{"importId":"import","maxChunkBytes":1}""" "workspace.export.read" -> { diff --git a/app/src/androidTest/java/dev/intent/app/testing/IntentTestTheme.kt b/app/src/androidTest/java/dev/intent/app/testing/IntentTestTheme.kt new file mode 100644 index 0000000..d707ecc --- /dev/null +++ b/app/src/androidTest/java/dev/intent/app/testing/IntentTestTheme.kt @@ -0,0 +1,21 @@ +package dev.intent.app.testing + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import dev.intent.app.design.IntentTheme + +@Composable +fun IntentTestTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { + IntentTheme(darkTheme) { + Surface( + Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + contentColor = MaterialTheme.colorScheme.onBackground, + content = content + ) + } +} diff --git a/app/src/main/java/dev/intent/app/agents/AgentGroups.kt b/app/src/main/java/dev/intent/app/agents/AgentGroups.kt new file mode 100644 index 0000000..eab9181 --- /dev/null +++ b/app/src/main/java/dev/intent/app/agents/AgentGroups.kt @@ -0,0 +1,46 @@ +package dev.intent.app.agents + +import dev.intent.app.design.AgentAvatarState +import dev.intent.core.protocol.models.Agent +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull + +internal enum class AgentGroup(val title: String) { + FOREGROUND("Agents"), + BACKGROUND("Background agents"), + ORPHANED("Orphaned delegated agents"), + RETIRED("Retired agents") +} + +internal data class AgentGroupSection(val group: AgentGroup, val agents: List) { + val running: Int get() = agents.count { it.avatarState() == AgentAvatarState.Running } +} + +internal fun Agent.delegationParent(): String? = parentAgentId?.takeIf(String::isNotBlank) + ?: createdByAgentId?.takeIf(String::isNotBlank) + ?: (metadata?.get("createdByAgentId") as? JsonPrimitive)?.contentOrNull + ?.takeIf(String::isNotBlank) + +internal fun Agent.isRetired(): Boolean = retiredAt != null || status == "retired" + +/** Classify complete trees before search so filtering never invents an orphan. */ +internal fun agentGroups(agents: List): List { + val groups = AgentGroup.entries.associateWith { mutableListOf() } + val active = agents.filterNot { it.isRetired() } + var group = AgentGroup.FOREGROUND + AgentTree(active).rows().forEach { row -> + if (row.depth == 0) { + group = when { + row.agent.delegationParent() != null -> AgentGroup.ORPHANED + (row.agent.metadata?.get("isBackground") as? JsonPrimitive) + ?.contentOrNull == "true" -> AgentGroup.BACKGROUND + else -> AgentGroup.FOREGROUND + } + } + groups.getValue(group).add(row.agent) + } + groups.getValue(AgentGroup.RETIRED).addAll(agents.filter { it.isRetired() }) + return groups.map { (key, rows) -> + AgentGroupSection(key, rows) + }.filter { it.agents.isNotEmpty() } +} diff --git a/app/src/main/java/dev/intent/app/agents/AgentListFeedback.kt b/app/src/main/java/dev/intent/app/agents/AgentListFeedback.kt new file mode 100644 index 0000000..af09d8f --- /dev/null +++ b/app/src/main/java/dev/intent/app/agents/AgentListFeedback.kt @@ -0,0 +1,28 @@ +package dev.intent.app.agents + +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import dev.intent.app.design.IntentTextButton as TextButton + +internal val AgentListState.hasAgents: Boolean + get() = agents.isNotEmpty() || retiredAgents.isNotEmpty() + +private val AgentListState.finishedLoading: Boolean + get() = !isLoading && !retiredLoading + +internal fun LazyListScope.agentListFeedback( + state: AgentListState, + noMatches: Boolean, + onRefresh: () -> Unit +) { + state.error?.let { item { Text(it, color = MaterialTheme.colorScheme.error) } } + if (state.finishedLoading && !state.hasAgents && state.error == null) { + item { Text("No agents yet") } + } + if (noMatches) item { Text("No matching agents. Change the search or select All.") } + if (state.retiredLoading) item { Text("Loading retired agents…") } + state.retiredError?.let { error -> + item { TextButton(onClick = onRefresh) { Text("$error · Retry") } } + } +} diff --git a/app/src/main/java/dev/intent/app/agents/AgentListFilter.kt b/app/src/main/java/dev/intent/app/agents/AgentListFilter.kt index 107224e..701e460 100644 --- a/app/src/main/java/dev/intent/app/agents/AgentListFilter.kt +++ b/app/src/main/java/dev/intent/app/agents/AgentListFilter.kt @@ -6,7 +6,7 @@ import dev.intent.core.protocol.models.Agent internal fun filterAgents(agents: List, query: String, currentOnly: Boolean): List { val search = query.trim() - return retainMatchingBranches(agents, { it.id }, { it.parentAgentId }) { + return retainMatchingBranches(agents, { it.id }, { it.delegationParent() }) { val current = it.avatarState() !in setOf(AgentAvatarState.Idle, AgentAvatarState.Completed) (!currentOnly || current) && it.name.contains(search, ignoreCase = true) } diff --git a/app/src/main/java/dev/intent/app/agents/AgentListScreen.kt b/app/src/main/java/dev/intent/app/agents/AgentListScreen.kt index 0244567..9081f88 100644 --- a/app/src/main/java/dev/intent/app/agents/AgentListScreen.kt +++ b/app/src/main/java/dev/intent/app/agents/AgentListScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -68,17 +69,16 @@ fun AgentListScreen( ) { var query by rememberSaveable { mutableStateOf("") } var currentOnly by rememberSaveable { mutableStateOf(false) } - var collapsed by rememberSaveable(query, currentOnly) { mutableStateOf(arrayListOf()) } - val tree = remember(state.agents, query, currentOnly) { - if (query.isBlank() && - !currentOnly - ) { - state.tree - } else { - AgentTree(filterAgents(state.agents, query, currentOnly)) - } + var expanded by rememberSaveable(query, currentOnly) { mutableStateOf(arrayListOf()) } + val groups = remember(state.agents, state.retiredAgents, query, currentOnly) { + agentGroups(state.agents + state.retiredAgents).map { section -> + section.copy(agents = filterAgents(section.agents, query, currentOnly)) + }.filter { it.agents.isNotEmpty() } + } + val searching = query.isNotBlank() || currentOnly + fun toggle(id: String) { + expanded = ArrayList(if (id in expanded) expanded - id else expanded + id) } - val rows = tree.rows(collapsed.toSet()) val resources = LocalWorkspaceResources.current Column(modifier) { WorkspaceSectionAppBar("Agents") @@ -86,12 +86,12 @@ fun AgentListScreen( query, { query = it }, "Search agents", - state.agents.isNotEmpty(), + state.hasAgents, currentOnly, { currentOnly = it } ) { contentPadding -> RefreshableList(state.isLoading, onRefresh) { - if (state.isLoading && state.agents.isEmpty()) { + if (state.isLoading && !state.hasAgents) { Spacer(Modifier.fillMaxSize()) } else { LazyColumn( @@ -99,33 +99,10 @@ fun AgentListScreen( contentPadding = contentPadding, verticalArrangement = Arrangement.spacedBy(4.dp) ) { - state.error?.let { - item { Text(it, color = MaterialTheme.colorScheme.error) } - } - if (!state.isLoading && state.error == null && state.agents.isEmpty()) { - item { Text("No agents yet") } - } - if (rows.isEmpty() && (query.isNotBlank() || currentOnly)) { - item { Text("No matching agents. Change the search or select All.") } - } - items(rows, key = { it.agent.id }) { row -> - AgentTreeItem( - row, - row.agent.id !in collapsed, - onToggle = { - collapsed = ArrayList( - if (row.agent.id in - collapsed - ) { - collapsed - row.agent.id - } else { - collapsed + row.agent.id - } - ) - }, - onClick = { onAgentSelected(row.agent.id) }, - onMute = onMute - ) + agentListFeedback(state, groups.isEmpty() && searching, onRefresh) + val display = AgentGroupDisplay(expanded.toSet(), searching, ::toggle) + groups.forEach { section -> + agentGroupItems(section, display, onAgentSelected, onMute) } if (resources != null) item { resources() } } @@ -135,6 +112,50 @@ fun AgentListScreen( } } +private data class AgentGroupDisplay( + val expanded: Set, + val searching: Boolean, + val toggle: (String) -> Unit +) + +private fun LazyListScope.agentGroupItems( + section: AgentGroupSection, + display: AgentGroupDisplay, + onAgentSelected: (String) -> Unit, + onMute: ((Agent) -> Unit)? +) { + val groupKey = "group-${section.group.name}" + val open = display.searching || + section.group == AgentGroup.FOREGROUND || + groupKey in display.expanded + if (section.group != AgentGroup.FOREGROUND) { + item(key = groupKey) { + TextButton(onClick = { display.toggle(groupKey) }) { + Text( + "${if (open) "▾" else "▸"} ${section.group.title}" + + " · ${section.agents.size} · ${section.running} running" + ) + } + } + } + if (open) { + val collapsed = if (display.searching) { + emptySet() + } else { + section.agents.map { it.id }.toSet() - display.expanded + } + items(AgentTree(section.agents).rows(collapsed), key = { it.agent.id }) { row -> + AgentTreeItem( + row, + display.searching || row.agent.id in display.expanded, + onToggle = { display.toggle(row.agent.id) }, + onClick = { onAgentSelected(row.agent.id) }, + onMute = onMute + ) + } + } +} + @Composable private fun AgentTreeItem( row: AgentTreeRow, @@ -145,7 +166,7 @@ private fun AgentTreeItem( ) { val primary = row.agent.isCoordinator() val color = if (primary) { - MaterialTheme.colorScheme.primaryContainer + MaterialTheme.colorScheme.surfaceContainerLow } else { MaterialTheme.colorScheme.surface } @@ -165,7 +186,7 @@ private fun AgentTreeItem( if (row.childCount > 0) { TextButton(onClick = onToggle) { Text( - "${if (expanded) "▾ Hide" else "▸ Show"} ${row.childCount} delegated agents" + "${if (expanded) "▾" else "▸"} ${row.childCount} delegated · ${row.runningChildCount} running" ) } } diff --git a/app/src/main/java/dev/intent/app/agents/AgentSession.kt b/app/src/main/java/dev/intent/app/agents/AgentSession.kt index 5188ea8..270d757 100644 --- a/app/src/main/java/dev/intent/app/agents/AgentSession.kt +++ b/app/src/main/java/dev/intent/app/agents/AgentSession.kt @@ -12,6 +12,7 @@ import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put interface AgentSession { + fun retired(): CompletableFuture> = CompletableFuture.completedFuture(emptyList()) fun list(): CompletableFuture> fun subscribe( onPush: (SubscriptionPush) -> Unit, @@ -28,6 +29,15 @@ class RpcAgentSession(private val session: RpcSession, private val workspaceId: AgentListResult.serializer() ).thenApply { it.agents } + override fun retired(): CompletableFuture> = session.request( + "agent.list", + buildJsonObject { + put("workspaceId", workspaceId) + put("retiredOnly", true) + }, + AgentListResult.serializer() + ).thenApply { it.agents } + override fun subscribe( onPush: (SubscriptionPush) -> Unit, onSubscribed: () -> Unit, diff --git a/app/src/main/java/dev/intent/app/agents/AgentStore.kt b/app/src/main/java/dev/intent/app/agents/AgentStore.kt index 0ae0ec7..c0f7546 100644 --- a/app/src/main/java/dev/intent/app/agents/AgentStore.kt +++ b/app/src/main/java/dev/intent/app/agents/AgentStore.kt @@ -10,7 +10,10 @@ import kotlinx.coroutines.flow.asStateFlow data class AgentListState( val agents: List = emptyList(), val isLoading: Boolean = true, - val error: String? = null + val error: String? = null, + val retiredAgents: List = emptyList(), + val retiredError: String? = null, + val retiredLoading: Boolean = false ) { val tree: AgentTree = AgentTree(agents) } @@ -23,6 +26,7 @@ class AgentStore(private val session: AgentSession, private val workspaceId: Str private var closed = false private var revision = 0L private var subscriptionGeneration = 0L + private var retiredRevision = 0L @Synchronized fun start() { @@ -43,13 +47,20 @@ class AgentStore(private val session: AgentSession, private val workspaceId: Str @Synchronized fun refresh() { if (closed) return + refreshRetired() val readRevision = ++revision mutableState.value = state.value.copy(isLoading = true, error = null) session.list().whenComplete { rows, error -> synchronized(this) { // A newer read, live push, reconnect, or close supersedes this response. if (!closed && revision == readRevision) { - if (error != null) fail(error) else publish(rows) + if (error != + null + ) { + fail(error) + } else { + mutableState.publish(rows + state.value.retiredAgents, workspaceId) + } } } } @@ -62,32 +73,42 @@ class AgentStore(private val session: AgentSession, private val workspaceId: Str val snapshot = push.snapshot val delta = push.delta when { - snapshot != null -> publish(snapshot) + snapshot != null -> mutableState.publish( + snapshot + state.value.retiredAgents, + workspaceId + ) delta != null -> { - val rows = state.value.agents.associateBy { it.id }.toMutableMap() + val reloadRetired = state.value.retiredLoading + retiredRevision++ + val rows = (state.value.agents + state.value.retiredAgents) + .associateBy { it.id }.toMutableMap() (delta.added + delta.updated).forEach { rows[it.id] = it } delta.removedIds.forEach(rows::remove) - publish(rows.values.toList()) + mutableState.publish(rows.values.toList(), workspaceId) + if (reloadRetired) refreshRetired() } else -> fail(IllegalArgumentException("Agent update has no data")) } } - private fun publish(rows: List) { - mutableState.value = AgentListState( - agents = rows.distinctBy { it.id }.filter { - (it.workspaceId == null || it.workspaceId == workspaceId) && - it.retiredAt == null && - it.status.lowercase() !in setOf("deleted", "retired") + private fun refreshRetired() { + val token = ++retiredRevision + mutableState.value = state.value.copy(retiredLoading = true, retiredError = null) + session.retired().whenComplete { rows, error -> + synchronized(this) { + if (!closed && token == retiredRevision) { + mutableState.value = state.value.copy( + retiredLoading = false, + retiredError = if (error != null) "Cannot load retired agents" else null, + retiredAgents = if (error != null) { + state.value.retiredAgents + } else { + scopedAgents(rows, workspaceId).filter { it.isRetired() } + } + ) + } } - .sortedWith( - compareByDescending { - it.lastActivity ?: it.updatedAt - }.thenBy { it.id } - ), - isLoading = false, - error = null - ) + } } @Synchronized @@ -107,19 +128,32 @@ class AgentStore(private val session: AgentSession, private val workspaceId: Str fun changed(agent: Agent) { if (closed) return revision++ - publish(state.value.agents.map { if (it.id == agent.id) agent else it }) + retiredRevision++ + mutableState.value = state.value.copy(retiredLoading = false) + mutableState.publish( + (state.value.agents + state.value.retiredAgents).map { + if (it.id == agent.id) agent else it + }, + workspaceId + ) } @Synchronized fun removed(agentId: String) { if (closed) return revision++ - publish(state.value.agents.filterNot { it.id == agentId }) + retiredRevision++ + mutableState.value = state.value.copy(retiredLoading = false) + mutableState.publish( + (state.value.agents + state.value.retiredAgents).filterNot { it.id == agentId }, + workspaceId + ) } @Synchronized fun stop() { subscriptionGeneration++ + retiredRevision++ revision++ lease?.close() lease = null @@ -131,3 +165,19 @@ class AgentStore(private val session: AgentSession, private val workspaceId: Str stop() } } + +private fun scopedAgents(rows: List, workspaceId: String) = rows.distinctBy { + it.id +}.filter { + (it.workspaceId == null || it.workspaceId == workspaceId) && it.status != "deleted" +}.sortedWith(compareByDescending { it.lastActivity ?: it.updatedAt }.thenBy { it.id }) + +private fun MutableStateFlow.publish(rows: List, workspaceId: String) { + val scoped = scopedAgents(rows, workspaceId) + value = value.copy( + agents = scoped.filterNot { it.isRetired() }, + retiredAgents = scoped.filter { it.isRetired() }, + isLoading = false, + error = null + ) +} diff --git a/app/src/main/java/dev/intent/app/agents/AgentTree.kt b/app/src/main/java/dev/intent/app/agents/AgentTree.kt index 5ed7b17..a8c0d99 100644 --- a/app/src/main/java/dev/intent/app/agents/AgentTree.kt +++ b/app/src/main/java/dev/intent/app/agents/AgentTree.kt @@ -1,10 +1,16 @@ package dev.intent.app.agents +import dev.intent.app.design.AgentAvatarState import dev.intent.core.protocol.models.Agent import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.contentOrNull -data class AgentTreeRow(val agent: Agent, val depth: Int, val childCount: Int) +data class AgentTreeRow( + val agent: Agent, + val depth: Int, + val childCount: Int, + val runningChildCount: Int = 0 +) /** Missing parents become roots. A visited set keeps malformed cycles visible and finite. */ class AgentTree(agents: List) { @@ -20,7 +26,7 @@ class AgentTree(agents: List) { .thenBy { it.id } ) private val ids = ordered.map { it.id }.toSet() - private val children = ordered.groupBy { it.parentAgentId } + private val children = ordered.groupBy { it.delegationParent() } fun rows(collapsed: Set = emptySet()): List { val result = mutableListOf() @@ -32,12 +38,23 @@ class AgentTree(agents: List) { val (agent, depth) = pending.removeLast() if (!visited.add(agent.id)) continue val descendants = children[agent.id].orEmpty().filter { it.id != agent.id } - if (depth >= 0) result.add(AgentTreeRow(agent, depth, descendants.size)) + if (depth >= 0) { + result.add( + AgentTreeRow( + agent, + depth, + descendants.size, + descendants.count { it.avatarState() == AgentAvatarState.Running } + ) + ) + } val nextDepth = if (depth < 0 || agent.id in collapsed) -1 else depth + 1 descendants.asReversed().forEach { pending.addLast(it to nextDepth) } } } - ordered.filter { it.parentAgentId !in ids || it.parentAgentId == it.id }.forEach(::visit) + ordered.filter { + it.delegationParent() !in ids || it.delegationParent() == it.id + }.forEach(::visit) ordered.filter { it.id !in visited }.forEach(::visit) return result } diff --git a/app/src/main/java/dev/intent/app/assistant/AssistantHistoryDialog.kt b/app/src/main/java/dev/intent/app/assistant/AssistantHistoryDialog.kt new file mode 100644 index 0000000..0d6ae5f --- /dev/null +++ b/app/src/main/java/dev/intent/app/assistant/AssistantHistoryDialog.kt @@ -0,0 +1,144 @@ +package dev.intent.app.assistant + +import android.text.format.DateUtils +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +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.platform.testTag +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import dev.intent.app.design.IntentButton as Button +import dev.intent.app.design.IntentScreen +import dev.intent.app.design.IntentTextButton as TextButton +import dev.intent.app.lists.RefreshableList +import dev.intent.core.protocol.models.Agent +import java.time.Instant +import java.time.format.DateTimeParseException + +@Composable +@Suppress("LongParameterList") // Dialog actions belong to the parent screen. +internal fun AssistantHistoryDialog( + state: AssistantState, + onDismiss: () -> Unit, + onRefresh: () -> Unit, + onCreate: () -> Unit, + onSelect: (String) -> Unit, + onDelete: (String) -> Unit +) { + Dialog(onDismissRequest = onDismiss) { + Surface(shape = MaterialTheme.shapes.large) { + Column { + Row( + Modifier.fillMaxWidth().padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + "Conversations", + Modifier.weight(1f).semantics { heading() }, + style = MaterialTheme.typography.titleMedium + ) + TextButton(onClick = onDismiss) { Text("Close history") } + } + AssistantError(state, onRefresh) + RefreshableList(state.loading, onRefresh) { + LazyColumn( + Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + item { + Button( + onClick = onCreate, + enabled = state.loaded && state.owner && !state.busy + ) { Text("New conversation") } + } + items(state.threads, key = { it.id }) { thread -> + AssistantHistoryRow(thread, state, onSelect, onDelete) + } + if (state.threads.isEmpty() && !state.loading && state.error == null) { + item { Text("No Assistant conversations yet.") } + } + } + } + } + } + } +} + +@Composable +internal fun AssistantError(state: AssistantState, onRetry: () -> Unit) { + state.error?.let { + Column(Modifier.padding(16.dp).semantics { liveRegion = LiveRegionMode.Polite }) { + Text(it, color = MaterialTheme.colorScheme.error) + TextButton(onClick = onRetry, enabled = !state.busy && !state.loading) { + Text("Retry Assistant") + } + } + } +} + +@Composable +private fun AssistantHistoryRow( + thread: Agent, + state: AssistantState, + onSelect: (String) -> Unit, + onDelete: (String) -> Unit +) { + val current = thread.id == state.selectedId + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + TextButton( + modifier = Modifier.weight(1f).testTag("assistant.history.${thread.id}") + .semantics { selected = current }, + onClick = { onSelect(thread.id) }, + enabled = !state.busy + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(thread.name, style = MaterialTheme.typography.titleSmall) + assistantHistoryTimestamp(thread)?.let { + Text( + DateUtils.getRelativeTimeSpanString(it).toString(), + style = MaterialTheme.typography.bodySmall, + color = IntentScreen.secondaryText + ) + } + } + if (current) Icon(Icons.Default.Check, null) + } + IconButton(onClick = { onDelete(thread.id) }, enabled = !state.busy) { + Icon(Icons.Default.Delete, "Delete ${thread.name}") + } + } +} + +internal fun assistantHistoryTimestamp(thread: Agent): Long? = + sequenceOf(thread.lastActivity, thread.updatedAt, thread.createdAt).mapNotNull { value -> + try { + value?.let { Instant.parse(it).toEpochMilli() } + } catch (_: DateTimeParseException) { + null + } catch (_: ArithmeticException) { + null + } + }.firstOrNull() diff --git a/app/src/main/java/dev/intent/app/assistant/AssistantScreen.kt b/app/src/main/java/dev/intent/app/assistant/AssistantScreen.kt index a5eeadc..a84d212 100644 --- a/app/src/main/java/dev/intent/app/assistant/AssistantScreen.kt +++ b/app/src/main/java/dev/intent/app/assistant/AssistantScreen.kt @@ -3,19 +3,14 @@ package dev.intent.app.assistant import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -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.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Delete +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -28,11 +23,17 @@ 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.res.painterResource +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import dev.intent.app.R import dev.intent.app.design.IntentButton as Button +import dev.intent.app.design.IntentScreen import dev.intent.app.design.IntentTextButton as TextButton -import dev.intent.app.lists.RefreshableList import dev.intent.app.navigation.ChromeAction import dev.intent.app.navigation.CompactAppBar import dev.intent.app.navigation.LocalChromeActions @@ -59,96 +60,44 @@ fun AssistantScreen( actions = listOf( ChromeAction("New conversation", state.loaded && state.owner && !state.busy) { store.create() - history = - false + history = false }, ChromeAction("Assistant history") { history = true }, ChromeAction("Refresh Assistant", !state.busy) { store.refresh() } ) ) - state.error?.let { - Text(it, Modifier.padding(16.dp), color = MaterialTheme.colorScheme.error) - TextButton(onClick = store::refresh) { Text("Retry Assistant") } - } + if (!history) AssistantError(state, store::refresh) when { state.owner && state.selectedId != null -> key(state.selectedId) { conversation(state.selectedId!!) } state.loading -> CircularProgressIndicator(Modifier.padding(24.dp)) - else -> Column( - Modifier.padding(24.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text("Plan your work and get help across all your workspaces.") - Button( - onClick = store::create, - enabled = - state.loaded && state.owner && !state.busy - ) { Text("Start a conversation") } - } + else -> AssistantEmptyState( + state.loaded && state.owner && !state.busy, + store::create + ) } } } if (history) { - androidx.compose.ui.window.Dialog(onDismissRequest = { history = false }) { - androidx.compose.material3.Surface(shape = MaterialTheme.shapes.large) { - Column { - TextButton(onClick = { history = false }) { Text("Close history") } - RefreshableList(state.loading, store::refresh) { - LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(16.dp)) { - item { - Button( - onClick = { - store.create() - history = false - }, - enabled = - state.loaded && state.owner && !state.busy - ) { Text("New conversation") } - } - items(state.threads, key = { it.id }) { thread -> - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - TextButton(modifier = Modifier.weight(1f), onClick = { - store.select(thread.id) - history = false - }) { - Text( - thread.name + - if (thread.id == - state.selectedId - ) { - " · Selected" - } else { - "" - } - ) - } - IconButton(onClick = { - deleting = thread.id - }, enabled = !state.busy) { - Icon(Icons.Default.Delete, "Delete ${thread.name}") - } - } - } - if (state.threads.isEmpty()) { - item { - Text("No Assistant conversations yet.") - } - } - } - } - } - } - } + AssistantHistoryDialog( + state = state, + onDismiss = { history = false }, + onRefresh = store::refresh, + onCreate = { + store.create() + history = false + }, + onSelect = { + store.select(it) + history = false + }, + onDelete = { deleting = it } + ) } deleting?.let { id -> AlertDialog( - onDismissRequest = { - deleting = null - }, + onDismissRequest = { deleting = null }, title = { Text("Delete conversation?") }, text = { Text("This removes the Assistant conversation and its messages.") }, confirmButton = { @@ -161,3 +110,25 @@ fun AssistantScreen( ) } } + +@Composable +private fun AssistantEmptyState(canCreate: Boolean, onCreate: () -> Unit) { + Column( + Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon(painterResource(R.drawable.avatar_chief_of_staff), null, Modifier.size(48.dp)) + Text( + "Assistant", + Modifier.semantics { heading() }, + style = MaterialTheme.typography.headlineSmall + ) + Text( + "Plan your work and get help across all your workspaces.", + color = IntentScreen.secondaryText, + textAlign = TextAlign.Center + ) + Button(onClick = onCreate, enabled = canCreate) { Text("Start a conversation") } + } +} diff --git a/app/src/main/java/dev/intent/app/browser/BrowserPage.kt b/app/src/main/java/dev/intent/app/browser/BrowserPage.kt index dc7d784..d980e5a 100644 --- a/app/src/main/java/dev/intent/app/browser/BrowserPage.kt +++ b/app/src/main/java/dev/intent/app/browser/BrowserPage.kt @@ -87,7 +87,13 @@ internal class BrowserPage(private val context: Context, private val store: Brow } override fun onPageFinished(view: WebView, url: String) { if (view.url != url) return - store.navigated(revision, address(url), view.canGoBack(), view.canGoForward()) + store.navigated( + revision, + address(url), + view.canGoBack(), + view.canGoForward(), + view.title + ) store.loaded(revision) } override fun onReceivedError( diff --git a/app/src/main/java/dev/intent/app/browser/BrowserStore.kt b/app/src/main/java/dev/intent/app/browser/BrowserStore.kt index e2145cd..206784a 100644 --- a/app/src/main/java/dev/intent/app/browser/BrowserStore.kt +++ b/app/src/main/java/dev/intent/app/browser/BrowserStore.kt @@ -16,7 +16,8 @@ data class BrowserState( val requestedUrl: String? = null, val canGoBack: Boolean = false, val canGoForward: Boolean = false, - val resolvedRevision: Long = 0 + val resolvedRevision: Long = 0, + val title: String? = null ) class BrowserStore( @@ -35,7 +36,13 @@ class BrowserStore( if (closed) return val ticket = ++generation mutableState.value = - state.value.copy(loading = true, error = null, revision = ticket, requestedUrl = value) + state.value.copy( + loading = true, + error = null, + revision = ticket, + requestedUrl = value, + title = null + ) try { PreviewUrl.parse(value) val pending = tunnel?.let { CompletableFuture.completedFuture(it) } ?: openTunnel() @@ -73,12 +80,19 @@ class BrowserStore( } @Synchronized - fun navigated(ticket: Long, address: String, canGoBack: Boolean, canGoForward: Boolean) { + fun navigated( + ticket: Long, + address: String, + canGoBack: Boolean, + canGoForward: Boolean, + title: String? = null + ) { if (!closed && ticket == generation) { mutableState.value = state.value.copy( requestedUrl = address, canGoBack = canGoBack, - canGoForward = canGoForward + canGoForward = canGoForward, + title = title ) } } diff --git a/app/src/main/java/dev/intent/app/browser/SavedBrowserPreferences.kt b/app/src/main/java/dev/intent/app/browser/SavedBrowserPreferences.kt new file mode 100644 index 0000000..91a629c --- /dev/null +++ b/app/src/main/java/dev/intent/app/browser/SavedBrowserPreferences.kt @@ -0,0 +1,32 @@ +package dev.intent.app.browser + +import android.content.Context +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json + +internal class SavedBrowserPreferences( + context: Context, + connectionId: String, + workspaceId: String +) : SavedBrowserPersistence { + private val preferences = context.getSharedPreferences( + "workspace-browsers", + Context.MODE_PRIVATE + ) + private val key = browserStorageKey(connectionId, workspaceId) + private val json = Json { ignoreUnknownKeys = true } + private val serializer = ListSerializer(SavedBrowser.serializer()) + + override fun load(): List = preferences.getString(key, null)?.let { + json.decodeFromString(serializer, it) + }.orEmpty() + + override fun save(browsers: List) { + check(preferences.edit().putString(key, json.encodeToString(serializer, browsers)).commit()) + } +} + +internal fun browserStorageKey(connectionId: String, workspaceId: String): String { + require(connectionId.isNotBlank() && workspaceId.isNotBlank()) + return "${connectionId.length}:$connectionId${workspaceId.length}:$workspaceId" +} diff --git a/app/src/main/java/dev/intent/app/browser/SavedBrowserStore.kt b/app/src/main/java/dev/intent/app/browser/SavedBrowserStore.kt new file mode 100644 index 0000000..ee43df0 --- /dev/null +++ b/app/src/main/java/dev/intent/app/browser/SavedBrowserStore.kt @@ -0,0 +1,111 @@ +package dev.intent.app.browser + +import dev.intent.core.protocol.browser.PreviewUrl +import java.util.UUID +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.serialization.Serializable + +@Serializable +data class SavedBrowser(val id: String, val title: String, val url: String) + +data class SavedBrowsersState( + val browsers: List = emptyList(), + val loading: Boolean = true, + val error: String? = null +) + +interface SavedBrowserPersistence { + fun load(): List + fun save(browsers: List) +} + +internal class MemorySavedBrowserPersistence : SavedBrowserPersistence { + private var browsers: List = emptyList() + override fun load(): List = browsers + override fun save(browsers: List) { + this.browsers = browsers.toList() + } +} + +internal fun browserPersistence( + connectionId: String, + workspaceId: String, + persistent: () -> SavedBrowserPersistence +): SavedBrowserPersistence = if (connectionId.isBlank() || workspaceId.isBlank()) { + MemorySavedBrowserPersistence() +} else { + persistent() +} + +/** Query strings and fragments can contain login tokens; only keep the page location on disk. */ +internal fun SavedBrowser.withoutUrlSecrets(): SavedBrowser { + val parsed = PreviewUrl.parse(url) + val location = "${parsed.scheme}://${parsed.rawAuthority}${parsed.rawPath.orEmpty()}" + return copy(url = location, title = if (location == url) title else location) +} + +/** Persistence is scoped to one saved host identity and workspace, never the current IP address. */ +class SavedBrowserStore(private val persistence: SavedBrowserPersistence) { + private val mutableState = MutableStateFlow(SavedBrowsersState()) + val state = mutableState.asStateFlow() + private var loaded = false + + @Synchronized + fun load() { + loaded = false + mutableState.value = state.value.copy(loading = true, error = null) + runCatching { + val original = persistence.load() + val browsers = original.mapNotNull { + runCatching { it.withoutUrlSecrets() }.getOrNull() + } + .distinctBy { it.id } + if (browsers != original) persistence.save(browsers) + browsers + }.onSuccess { browsers -> + loaded = true + mutableState.value = SavedBrowsersState( + browsers = browsers, + loading = false + ) + }.onFailure { + mutableState.value = state.value.copy( + loading = false, + error = "Cannot load saved browsers. Retry to recover them." + ) + } + } + + @Synchronized + fun savePage(id: String?, url: String, title: String?): String? { + if (!loaded || state.value.loading) return null + return runCatching { + val page = SavedBrowser("", title?.trim()?.takeIf { it.isNotEmpty() } ?: url, url) + .withoutUrlSecrets() + val existing = state.value.browsers.firstOrNull { it.id == id } + ?: state.value.browsers.firstOrNull { it.url == page.url } + val browser = page.copy(id = existing?.id ?: UUID.randomUUID().toString()) + val browsers = state.value.browsers.filterNot { it.id == browser.id } + browser + persistence.save(browsers) + mutableState.value = SavedBrowsersState(browsers, loading = false) + browser.id + }.getOrElse { + mutableState.value = + state.value.copy(error = "Cannot save this browser. Return to the list and retry.") + null + } + } + + @Synchronized + fun remove(id: String) { + if (!loaded || state.value.loading) return + val browsers = state.value.browsers.filterNot { it.id == id } + runCatching { persistence.save(browsers) }.onSuccess { + mutableState.value = SavedBrowsersState(browsers, loading = false) + }.onFailure { + mutableState.value = + state.value.copy(error = "Cannot remove this browser. Retry to try again.") + } + } +} diff --git a/app/src/main/java/dev/intent/app/browser/WorkspaceBrowser.kt b/app/src/main/java/dev/intent/app/browser/WorkspaceBrowser.kt index d8b2074..58f7078 100644 --- a/app/src/main/java/dev/intent/app/browser/WorkspaceBrowser.kt +++ b/app/src/main/java/dev/intent/app/browser/WorkspaceBrowser.kt @@ -10,6 +10,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.viewinterop.AndroidView @@ -23,7 +24,8 @@ fun WorkspaceBrowser( session: RpcSession, workspaceId: String, onBack: () -> Unit, - initialUrl: String? = null + initialUrl: String? = null, + onPageChanged: (String, String?) -> Unit = { _, _ -> } ) { val store = remember(session, workspaceId) { BrowserStore(session) } val context = LocalContext.current @@ -36,6 +38,12 @@ fun WorkspaceBrowser( } LaunchedEffect(store, initialUrl) { initialUrl?.let(store::open) } val state by store.state.collectAsState() + val pageChanged by rememberUpdatedState(onPageChanged) + LaunchedEffect(state.requestedUrl, state.title, state.loading, state.error) { + if (!state.loading && state.error == null && state.url != null) { + state.requestedUrl?.let { pageChanged(it, state.title) } + } + } BrowserScreen( state, onBack, diff --git a/app/src/main/java/dev/intent/app/browser/WorkspaceBrowsersRoute.kt b/app/src/main/java/dev/intent/app/browser/WorkspaceBrowsersRoute.kt new file mode 100644 index 0000000..ad7513e --- /dev/null +++ b/app/src/main/java/dev/intent/app/browser/WorkspaceBrowsersRoute.kt @@ -0,0 +1,154 @@ +package dev.intent.app.browser + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import dev.intent.app.design.IntentScreen +import dev.intent.app.design.IntentSection +import dev.intent.app.design.IntentTextButton +import dev.intent.core.protocol.session.RpcSession +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** A blank connectionId uses session-local memory; otherwise use the stable saved-connection ID. */ +@Composable +fun WorkspaceBrowsersRoute( + session: RpcSession, + connectionId: String, + workspaceId: String, + onBack: () -> Unit +) { + val context = LocalContext.current.applicationContext + val store = remember(session, connectionId, workspaceId) { + SavedBrowserStore( + browserPersistence(connectionId, workspaceId) { + SavedBrowserPreferences(context, connectionId, workspaceId) + } + ) + } + val scope = rememberCoroutineScope() + val state by store.state.collectAsState() + var target by rememberSaveable(session, connectionId, workspaceId) { + mutableStateOf(null) + } + var selectedId by rememberSaveable(session, connectionId, workspaceId) { + mutableStateOf(null) + } + LaunchedEffect(store) { withContext(Dispatchers.IO) { store.load() } } + BackHandler(target != null) { target = null } + if (target != null && !state.loading) { + WorkspaceBrowser( + session, + workspaceId, + onBack = { target = null }, + initialUrl = target?.takeIf { it.isNotEmpty() }, + onPageChanged = { url, title -> + val pageTarget = target + val pageId = selectedId + scope.launch { + val savedId = withContext(Dispatchers.IO) { store.savePage(pageId, url, title) } + if (target == pageTarget) selectedId = savedId ?: selectedId + } + } + ) + } else { + WorkspaceBrowsersScreen( + state, + onBack, + onNew = { + selectedId = null + target = "" + }, + onOpen = { + selectedId = it.id + target = it.url + }, + onRemove = { id -> scope.launch(Dispatchers.IO) { store.remove(id) } }, + onRetry = { scope.launch(Dispatchers.IO) { store.load() } } + ) + } +} + +@Suppress("LongParameterList") // Pure screen callbacks allow host-free screenshots. +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun WorkspaceBrowsersScreen( + state: SavedBrowsersState, + onBack: () -> Unit, + onNew: () -> Unit, + onOpen: (SavedBrowser) -> Unit, + onRemove: (String) -> Unit, + onRetry: () -> Unit +) { + LazyColumn( + Modifier.fillMaxSize(), + contentPadding = IntentScreen.pageInsets, + verticalArrangement = Arrangement.spacedBy(IntentScreen.itemSpacing) + ) { + item { + Column { + Text("Browsers", style = MaterialTheme.typography.headlineSmall) + Text("Under development", color = IntentScreen.secondaryText) + FlowRow { + IntentTextButton(onClick = onBack) { Text("Back") } + IntentTextButton( + onClick = onNew, + enabled = + !state.loading && state.error == null + ) { + Text("New browser") + } + } + } + } + if (state.loading) item { LinearProgressIndicator(Modifier.fillMaxWidth()) } + state.error?.let { message -> + item { + Text(message, color = IntentScreen.error) + IntentTextButton(onClick = onRetry, enabled = !state.loading) { Text("Retry") } + } + } + if (!state.loading && state.browsers.isEmpty() && state.error == null) { + item { + IntentSection("No saved browsers") { + Text("Open a workspace preview to save its title and URL here.") + } + } + } + items(state.browsers, key = { it.id }) { browser -> + IntentSection(browser.title, onClick = { onOpen(browser) }) { + Text( + browser.url, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + color = IntentScreen.secondaryText + ) + IntentTextButton(onClick = { onRemove(browser.id) }, enabled = !state.loading) { + Text("Remove saved browser") + } + } + } + } +} diff --git a/app/src/main/java/dev/intent/app/connection/ConnectionController.kt b/app/src/main/java/dev/intent/app/connection/ConnectionController.kt index 60af8cf..4ac3439 100644 --- a/app/src/main/java/dev/intent/app/connection/ConnectionController.kt +++ b/app/src/main/java/dev/intent/app/connection/ConnectionController.kt @@ -5,7 +5,11 @@ import dev.intent.core.protocol.pairing.ManualPairingInput import dev.intent.core.protocol.pairing.PairingPayload import dev.intent.core.protocol.session.RpcSession import dev.intent.core.protocol.session.SessionState +import dev.intent.tailcat.Tailcat +import dev.intent.tailcat.TunnelClientFactory +import dev.intent.tailcat.TunnelUnavailableException import java.io.Closeable +import java.net.HttpURLConnection import java.util.concurrent.Executors import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.flow.MutableStateFlow @@ -30,6 +34,7 @@ data class ConnectionState( class ConnectionController( private val repository: ConnectionRepository, private val sessionFactory: ((SavedConnection, (SessionState) -> Unit) -> RpcSession)? = null, + private val tunnels: TunnelClientFactory = Tailcat, private val probe: ( String, Int @@ -45,6 +50,7 @@ class ConnectionController( private var selectedPath: ConnectionPath? = null private var namedAdmission = -1L val diagnostics = ConnectionDiagnostics() + val tunnelAvailable: Boolean get() = tunnels.available fun restore() = submit { val rows = repository.list() @@ -60,7 +66,7 @@ class ConnectionController( private fun prepare(payload: PairingPayload) { mutableState.value = state.value.copy(pending = null, busy = true, error = null) - require(payload.hosts.isNotEmpty()) { "A direct host is required" } + checkTunnelSupport(payload) val fingerprint = payload.certFingerprint?.let(CertificateFingerprint::normalize) mutableState.value = state.value.copy( pending = payload.copy(certFingerprint = fingerprint), @@ -71,7 +77,7 @@ class ConnectionController( fun probeCertificate() = submit { val payload = state.value.pending ?: return@submit mutableState.value = state.value.copy(busy = true) - val fingerprint = probe(payload.hosts.first().removeSurrounding("[", "]"), payload.port) + val fingerprint = probePairingCertificate(payload, tunnels, probe) mutableState.value = state.value.copy( pending = payload.copy(certFingerprint = fingerprint), busy = false @@ -107,6 +113,7 @@ class ConnectionController( } private fun activate(connection: SavedConnection) { + checkTunnelSupport(connection.payload) repository.select(connection.id) stopSession() val ticket = generation @@ -124,6 +131,7 @@ class ConnectionController( repository.list().firstOrNull { it.id == connection.id }?.payload ?: connection.payload }, + tunnels = tunnels, onRoute = { path -> submit { if (ticket == generation) selectedPath = path } } ) current?.connect() @@ -139,10 +147,15 @@ class ConnectionController( } diagnostics.record(ConnectionEvent.valueOf(status.name)) if (status == SessionState.CLOSED) { + val message = when (current?.rejectedStatusCode) { + HttpURLConnection.HTTP_UNAUTHORIZED -> + "Pairing token rejected. Pair again with the desktop's current details." + HttpURLConnection.HTTP_FORBIDDEN -> + "Connection refused. Enable Mobile access in desktop Settings." + else -> "Cannot verify identity. Reconnect to try again." + } stopSession() - mutableState.value = state.value.copy( - error = "Cannot verify identity. Reconnect to try again." - ) + mutableState.value = state.value.copy(error = message) return } // Preserve the admitted session and Compose ownership during a transport retry. @@ -231,22 +244,28 @@ class ConnectionController( ) } + private fun checkTunnelSupport(payload: PairingPayload) { + if (payload.hosts.isEmpty() && !tunnels.available) throw TunnelUnavailableException() + } + @Synchronized private fun submit(action: () -> Unit) { if (closed.get()) return executor.execute { try { action() + } catch (_: TunnelUnavailableException) { + actionFailed(TUNNEL_UNAVAILABLE_MESSAGE) } catch (_: Exception) { - mutableState.value = state.value.copy( - busy = false, - pending = null, - error = "Cannot complete this action. Check the connection details." - ) + actionFailed("Cannot complete this action. Check the connection details.") } } } + private fun actionFailed(message: String) { + mutableState.value = state.value.copy(busy = false, pending = null, error = message) + } + @Synchronized override fun close() { if (!closed.compareAndSet(false, true)) return diff --git a/app/src/main/java/dev/intent/app/connection/ConnectionRecoveryBanner.kt b/app/src/main/java/dev/intent/app/connection/ConnectionRecoveryBanner.kt new file mode 100644 index 0000000..0427002 --- /dev/null +++ b/app/src/main/java/dev/intent/app/connection/ConnectionRecoveryBanner.kt @@ -0,0 +1,36 @@ +package dev.intent.app.connection + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import dev.intent.app.design.IntentScreen +import dev.intent.app.design.IntentTextButton + +internal val ConnectionState.isRecovering: Boolean + get() = session != null && status != "Connected" + +@Composable +internal fun ConnectionRecoveryBanner(state: ConnectionState, onConnections: () -> Unit) { + if (!state.isRecovering) return + Surface(color = MaterialTheme.colorScheme.surfaceVariant) { + Column( + Modifier.fillMaxWidth().padding(IntentScreen.itemSpacing) + .semantics { liveRegion = LiveRegionMode.Polite } + ) { + Text("Reconnecting…", style = MaterialTheme.typography.titleSmall) + Text( + "Connection lost. Your screen is kept while we retry. Check your network and desktop.", + style = MaterialTheme.typography.bodySmall + ) + IntentTextButton(onClick = onConnections) { Text("Saved connections") } + } + } +} diff --git a/app/src/main/java/dev/intent/app/connection/ConnectionRoot.kt b/app/src/main/java/dev/intent/app/connection/ConnectionRoot.kt index 13606fa..f1d9b30 100644 --- a/app/src/main/java/dev/intent/app/connection/ConnectionRoot.kt +++ b/app/src/main/java/dev/intent/app/connection/ConnectionRoot.kt @@ -2,6 +2,8 @@ package dev.intent.app.connection import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -19,6 +21,7 @@ import androidx.compose.ui.platform.LocalContext import dev.intent.app.attachments.AttachmentMedia import dev.intent.app.attachments.LocalAttachmentMedia import dev.intent.app.cache.RoomOfflineCache +import dev.intent.app.design.IntentScreen import dev.intent.app.drawers.AgentHistoryDrawer import dev.intent.app.drawers.ConnectionsDrawer import dev.intent.app.drawers.DrawerPanels @@ -76,6 +79,12 @@ fun ConnectionRoot( onSettings = { utilities = utilities.openUtility("Settings") } ) CompositionLocalProvider( + dev.intent.app.navigation.LocalConnectionReady provides (state.status == "Connected"), + dev.intent.app.navigation.LocalConnectionName provides + ( + state.connections.firstOrNull { it.id == state.activeId }?.displayName + ?: "Connected host" + ), dev.intent.app.navigation.LocalLauncherActions provides launcher, LocalHistoryNavigation provides HistoryNavigation(history, state.activeId, historyTarget) { historyTarget = null }, @@ -100,7 +109,6 @@ fun ConnectionRoot( ) ) { Column(modifier) { - state.error?.let { Text(it) } val utilityTitle = when { utility == "Settings" -> "Settings" utility == "Pair connection" || state.connections.isEmpty() -> "Pair connection" @@ -113,13 +121,22 @@ fun ConnectionRoot( onBack = if (utilities.isNotEmpty()) back else null ) } + state.error?.let { + Text( + it, + modifier = Modifier.padding(IntentScreen.pageInsets), + color = IntentScreen.error, + style = MaterialTheme.typography.bodyMedium + ) + } when { utility == "Settings" -> ConnectionSettings( controller.diagnostics, Modifier.weight(1f), preferences, state, - onDisconnect = controller::disconnect + onDisconnect = controller::disconnect, + onPair = { utilities = utilities.openUtility("Pair connection") } ) { utilities = utilities.openUtility("Connections") } @@ -127,12 +144,13 @@ fun ConnectionRoot( state.busy, onScan, controller::prepareManual, - Modifier.weight(1f) + Modifier.weight(1f), + tunnelAvailable = controller.tunnelAvailable ) utility == "Connections" || state.session == null -> ConnectionScreen( state, onSelect = { - controller.select(it) + if (it != state.activeId || state.session == null) controller.select(it) utilities = emptyList() }, onForget = controller::forget, @@ -141,7 +159,12 @@ fun ConnectionRoot( ) else -> key(state.session, state.cacheScope) { contentState.SaveableStateProvider(state.cacheScope ?: "connected") { - ConnectedContent(state, Modifier.weight(1f), notificationWorkspace) + Column(Modifier.weight(1f)) { + ConnectionRecoveryBanner(state) { + utilities = utilities.openUtility("Connections") + } + ConnectedContent(state, Modifier.weight(1f), notificationWorkspace) + } } } } @@ -151,7 +174,7 @@ fun ConnectionRoot( PairingConfirmation(payload, state.busy, controller::probeCertificate, { controller.confirmPairing() utilities = emptyList() - }, controller::cancelPairing) + }, controller::cancelPairing, controller.tunnelAvailable) } } diff --git a/app/src/main/java/dev/intent/app/connection/ConnectionScreen.kt b/app/src/main/java/dev/intent/app/connection/ConnectionScreen.kt index 6e4810c..0f0240a 100644 --- a/app/src/main/java/dev/intent/app/connection/ConnectionScreen.kt +++ b/app/src/main/java/dev/intent/app/connection/ConnectionScreen.kt @@ -107,7 +107,14 @@ private fun SavedConnectionSection( ConnectionPathBadge(state, connection.id) Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { TextButton(onClick = { onSelect(connection.id) }, enabled = !state.busy) { - Text(if (state.activeId == connection.id) "Reconnect" else "Connect") + Text( + when { + state.activeId == connection.id && state.session != null -> + "Return to workspace" + state.activeId == connection.id -> "Reconnect" + else -> "Connect" + } + ) } Box { TextButton(onClick = { menuOpen = true }) { Text("Manage") } diff --git a/app/src/main/java/dev/intent/app/connection/ConnectionSettings.kt b/app/src/main/java/dev/intent/app/connection/ConnectionSettings.kt index 56aaeff..4bc72a2 100644 --- a/app/src/main/java/dev/intent/app/connection/ConnectionSettings.kt +++ b/app/src/main/java/dev/intent/app/connection/ConnectionSettings.kt @@ -6,19 +6,30 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState 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.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog import dev.intent.app.BuildConfig +import dev.intent.app.R import dev.intent.app.design.IntentScreen import dev.intent.app.design.IntentSection import dev.intent.app.design.IntentTextButton as TextButton @@ -35,6 +46,7 @@ fun ConnectionSettings( preferences: AppPreferences, state: ConnectionState = ConnectionState(), onDisconnect: (() -> Unit)? = null, + onPair: (() -> Unit)? = null, onConnections: () -> Unit ) { val context = LocalContext.current @@ -47,6 +59,7 @@ fun ConnectionSettings( rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { permissionFlow.onPermissionResult(it) } + var acknowledgements by rememberSaveable { mutableStateOf(false) } var report by remember { mutableStateOf(null) } Column( modifier.verticalScroll(rememberScrollState()).intentPageInsets(), @@ -72,6 +85,14 @@ fun ConnectionSettings( }) { Text("Android notification settings") } } ConnectionsSection(state, onConnections) + onPair?.let { pair -> + IntentSection("Pair new computer") { + SettingsDescription( + "Scan a QR code or enter connection details from Intent on your computer." + ) + TextButton(onClick = pair) { Text("Pair computer") } + } + } IntentSection("Diagnostics") { SettingsDescription("Connection diagnostics — under development") TextButton(onClick = { report = diagnostics.report() }) { Text("View diagnostics") } @@ -91,10 +112,13 @@ fun ConnectionSettings( } } AboutSection() + TextButton(onClick = { acknowledgements = true }) { Text("Acknowledgements") } if (state.activeId != null && onDisconnect != null) { + SettingsDescription("Disconnect keeps this computer saved so you can reconnect later.") TextButton(onClick = onDisconnect) { Text("Disconnect") } } } + if (acknowledgements) AcknowledgementsDialog { acknowledgements = false } } @Composable @@ -133,3 +157,46 @@ private fun ConnectionsSection(state: ConnectionState, onConnections: () -> Unit TextButton(onClick = onConnections) { Text("Manage connections") } } } + +@Composable +private fun AcknowledgementsDialog(onDismiss: () -> Unit) { + val context = LocalContext.current + val licenses = remember(context) { + listOf( + "Inter" to context.resources.openRawResource(R.raw.inter_license) + .bufferedReader().use { it.readText() }, + "Mermaid renderer" to context.assets.open("mermaid/NOTICE.txt") + .bufferedReader().use { it.readText() } + ) + } + Dialog(onDismissRequest = onDismiss) { + Surface(shape = MaterialTheme.shapes.large) { + Column(Modifier.padding(16.dp)) { + Text( + "Acknowledgements", + Modifier.semantics { heading() }, + style = MaterialTheme.typography.titleMedium + ) + TextButton(onClick = onDismiss) { Text("Close acknowledgements") } + LazyColumn( + Modifier.fillMaxSize().testTag("settings.acknowledgements"), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + item { + SettingsDescription("License notices bundled with this app.") + } + licenses.forEach { (name, license) -> + item { + Text( + name, + Modifier.semantics { heading() }, + style = MaterialTheme.typography.titleSmall + ) + Text(license, style = MaterialTheme.typography.bodySmall) + } + } + } + } + } + } +} diff --git a/app/src/main/java/dev/intent/app/connection/HostRacingTransport.kt b/app/src/main/java/dev/intent/app/connection/HostRacingTransport.kt index 1696a74..302512a 100644 --- a/app/src/main/java/dev/intent/app/connection/HostRacingTransport.kt +++ b/app/src/main/java/dev/intent/app/connection/HostRacingTransport.kt @@ -2,6 +2,7 @@ package dev.intent.app.connection import dev.intent.core.protocol.CertificateFingerprint import dev.intent.core.protocol.JsonRpcMessage +import dev.intent.core.protocol.WebSocketUpgradeException import dev.intent.core.protocol.WssTransport import dev.intent.core.protocol.pairing.PairingPayload import dev.intent.core.protocol.session.SessionTransport @@ -9,6 +10,7 @@ import dev.intent.core.protocol.session.pinnedSessionTransport import dev.intent.tailcat.Tailcat import dev.intent.tailcat.TunnelClientFactory import java.io.IOException +import java.net.HttpURLConnection import java.util.concurrent.Executors import java.util.concurrent.RejectedExecutionException import java.util.concurrent.TimeUnit @@ -35,6 +37,7 @@ internal class HostRacingTransport( private val closed = AtomicBoolean() private val started = AtomicBoolean() private val candidates = mutableListOf() + private var rejection: WebSocketUpgradeException? = null @Volatile private var winner: Candidate? = null val endpoint: String? get() = if (closed.get()) null else winner?.endpoint @@ -144,10 +147,18 @@ internal class HostRacingTransport( private fun failed(candidate: Candidate, error: Throwable) { if (candidate.failed) return candidate.failed = true + if (error is WebSocketUpgradeException && + error.statusCode in listOf( + HttpURLConnection.HTTP_UNAUTHORIZED, + HttpURLConnection.HTTP_FORBIDDEN + ) + ) { + rejection = error + } RouteDiagnostics.record(candidate.route, "attempt", "failure", error) candidate.transport?.close() if (winner === candidate || (winner == null && candidates.all { it.failed })) { - listener.onFailure(error) + listener.onFailure(if (winner == null) rejection ?: error else error) } } diff --git a/app/src/main/java/dev/intent/app/connection/ManualPairingErrors.kt b/app/src/main/java/dev/intent/app/connection/ManualPairingErrors.kt index 95f7c8d..a4f2039 100644 --- a/app/src/main/java/dev/intent/app/connection/ManualPairingErrors.kt +++ b/app/src/main/java/dev/intent/app/connection/ManualPairingErrors.kt @@ -12,37 +12,43 @@ internal data class ManualPairingErrors( val isValid: Boolean get() = listOf(host, port, token, fingerprint).all { it == null } } -internal fun manualPairingErrors(input: ManualPairingInput): ManualPairingErrors = - ManualPairingErrors( - host = if (runCatching { - input.copy(port = "443", token = "validation").toPayload() - }.isFailure - ) { - "Enter a host name or IP address." - } else { - null - }, - port = if (runCatching { - input.copy(host = "desktop", token = "validation").toPayload() - }.isFailure - ) { - "Enter a port from 1 to 65535." - } else { - null - }, - token = if (runCatching { - input.copy(host = "desktop", port = "443").toPayload() - }.isFailure - ) { - "Enter the pairing token from your desktop." - } else { - null - }, - fingerprint = if (input.certFingerprint.isNotBlank() && - runCatching { CertificateFingerprint.normalize(input.certFingerprint.trim()) }.isFailure - ) { - "Enter 64 hexadecimal digits, with optional colons." - } else { - null - } - ) +internal fun manualPairingErrors( + input: ManualPairingInput, + tunnelAvailable: Boolean = true +): ManualPairingErrors = ManualPairingErrors( + host = if (runCatching { + input.copy(port = "443", token = "validation").toPayload() + }.isFailure + ) { + "Enter a host name, IP address, or Tailcat address." + } else if (!tunnelAvailable && + input.copy(port = "443", token = "validation").toPayload().hosts.isEmpty() + ) { + TUNNEL_UNAVAILABLE_MESSAGE + } else { + null + }, + port = if (runCatching { + input.copy(host = "desktop", token = "validation").toPayload() + }.isFailure + ) { + "Enter a port from 1 to 65535." + } else { + null + }, + token = if (runCatching { + input.copy(host = "desktop", port = "443").toPayload() + }.isFailure + ) { + "Enter the pairing token from your desktop." + } else { + null + }, + fingerprint = if (input.certFingerprint.isNotBlank() && + runCatching { CertificateFingerprint.normalize(input.certFingerprint.trim()) }.isFailure + ) { + "Enter 64 hexadecimal digits, with optional colons." + } else { + null + } +) diff --git a/app/src/main/java/dev/intent/app/connection/PairingCertificateProbe.kt b/app/src/main/java/dev/intent/app/connection/PairingCertificateProbe.kt new file mode 100644 index 0000000..cb61794 --- /dev/null +++ b/app/src/main/java/dev/intent/app/connection/PairingCertificateProbe.kt @@ -0,0 +1,29 @@ +package dev.intent.app.connection + +import dev.intent.core.protocol.pairing.PairingPayload +import dev.intent.tailcat.TunnelClientFactory +import dev.intent.tailcat.TunnelUnavailableException + +private const val PING_TIMEOUT_MILLIS = 5000 +private const val MAX_PORT = 65535 + +internal const val TUNNEL_UNAVAILABLE_MESSAGE = + "Tailcat is unavailable in this build. Use a host name or IP address from your desktop instead." + +/** Fetches only the certificate: no bearer credentials are sent before the user confirms trust. */ +internal fun probePairingCertificate( + payload: PairingPayload, + tunnels: TunnelClientFactory, + probe: (String, Int) -> String +): String { + val host = payload.hosts.firstOrNull() + if (host != null) return probe(host.removeSurrounding("[", "]"), payload.port) + if (!tunnels.available) throw TunnelUnavailableException() + return tunnels.createClient(requireNotNull(payload.tcAddress)).use { client -> + client.ping(PING_TIMEOUT_MILLIS) + client.forward(payload.port).use { forwarder -> + require(forwarder.localPort in 1..MAX_PORT) + probe("127.0.0.1", forwarder.localPort) + } + } +} diff --git a/app/src/main/java/dev/intent/app/connection/PairingScannerActivity.kt b/app/src/main/java/dev/intent/app/connection/PairingScannerActivity.kt index 7f60be2..c5b241b 100644 --- a/app/src/main/java/dev/intent/app/connection/PairingScannerActivity.kt +++ b/app/src/main/java/dev/intent/app/connection/PairingScannerActivity.kt @@ -15,4 +15,12 @@ class PairingScannerActivity : CaptureActivity() { } return findViewById(R.id.zxing_barcode_scanner) } + + override fun onResume() { + super.onResume() + findViewById(R.id.zxing_barcode_scanner) + .setStatusText( + "On your desktop, open Settings → Mobile. Point the camera at the QR code." + ) + } } diff --git a/app/src/main/java/dev/intent/app/connection/PairingScreen.kt b/app/src/main/java/dev/intent/app/connection/PairingScreen.kt index 9bf2f36..8fc7626 100644 --- a/app/src/main/java/dev/intent/app/connection/PairingScreen.kt +++ b/app/src/main/java/dev/intent/app/connection/PairingScreen.kt @@ -19,6 +19,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.tooling.preview.Preview import dev.intent.app.design.IntentButton as Button import dev.intent.app.design.IntentScreen @@ -28,13 +29,15 @@ import dev.intent.app.design.IntentTextField as OutlinedTextField import dev.intent.app.design.intentPageInsets import dev.intent.core.protocol.pairing.ManualPairingInput import dev.intent.core.protocol.pairing.PairingPayload +import dev.intent.tailcat.Tailcat @Composable fun PairingScreen( busy: Boolean, onScan: () -> Unit, onManual: (ManualPairingInput) -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + tunnelAvailable: Boolean = Tailcat.available ) { var manual by rememberSaveable { mutableStateOf(false) } BackHandler(enabled = manual && !busy) { manual = false } @@ -46,79 +49,71 @@ fun PairingScreen( TextButton(onClick = { manual = false }, enabled = !busy) { Text("Back to pairing options") } - ManualPairingForm(busy, onManual) + ManualPairingForm(busy, onManual, tunnelAvailable) } else { Text("Connect to Intent", style = MaterialTheme.typography.headlineSmall) Text( "On your desktop, open Settings → Mobile to get the pairing code and connection details.", color = IntentScreen.secondaryText ) - IntentSection("Scan a QR code") { - Text("Use your camera to scan the code on your desktop.") - Button(onClick = onScan, enabled = !busy, modifier = Modifier.fillMaxWidth()) { - Text("Scan QR code") - } - } - IntentSection("Manual connection") { - Text("Enter the host, port, and pairing token from your desktop.") - TextButton( - onClick = { manual = true }, - enabled = !busy, - modifier = Modifier.fillMaxWidth() - ) { Text("Enter connection details") } + Button(onClick = onScan, enabled = !busy, modifier = Modifier.fillMaxWidth()) { + Text("Scan QR code") } + TextButton( + onClick = { manual = true }, + enabled = !busy, + modifier = Modifier.fillMaxWidth() + ) { Text("Enter connection details") } Text("The Android companion is under development.", color = IntentScreen.secondaryText) } } } @Composable -private fun ManualPairingForm(busy: Boolean, onManual: (ManualPairingInput) -> Unit) { +private fun ManualPairingForm( + busy: Boolean, + onManual: (ManualPairingInput) -> Unit, + tunnelAvailable: Boolean +) { var host by rememberSaveable { mutableStateOf("") } - var port by rememberSaveable { mutableStateOf("") } + var port by rememberSaveable { mutableStateOf("5181") } var token by remember { mutableStateOf("") } var fingerprint by rememberSaveable { mutableStateOf("") } val input = ManualPairingInput(host, port, token, fingerprint) - val errors = manualPairingErrors(input) + val errors = manualPairingErrors(input, tunnelAvailable) IntentSection("Manual connection") { OutlinedTextField( host, { host = it }, - label = { Text("Host") }, + label = { Text("Host / Tailcat address") }, singleLine = true, modifier = Modifier.fillMaxWidth(), enabled = !busy, isError = host.isNotEmpty() && errors.host != null, supportingText = { - Text(errors.host.takeIf { host.isNotEmpty() } ?: "Host name or IP address") + Text( + errors.host.takeIf { + host.isNotEmpty() + } ?: "Host name, IP, or Tailcat address" + ) } ) + if (!tunnelAvailable) { + Text(TUNNEL_UNAVAILABLE_MESSAGE, color = IntentScreen.secondaryText) + } OutlinedTextField( port, { port = it }, label = { Text("Port") }, singleLine = true, modifier = Modifier.fillMaxWidth(), enabled = !busy, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), isError = port.isNotEmpty() && errors.port != null, - supportingText = { - Text(errors.port.takeIf { port.isNotEmpty() } ?: "Port from your desktop") - } - ) - OutlinedTextField( - token, { token = it }, label = { Text("Pairing token") }, singleLine = true, - modifier = Modifier.fillMaxWidth(), enabled = !busy, - visualTransformation = PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), - isError = token.isNotEmpty() && errors.token != null, supportingText = { Text( - if (token.isNotEmpty()) { - errors.token - ?: "Token from your desktop" - } else { - "Token from your desktop" - } + errors.port.takeIf { port.isNotEmpty() } + ?: "Default 5181; use the port on your desktop" ) } ) + PairingTokenField(token, { token = it }, busy, errors.token) FingerprintField(fingerprint, { fingerprint = it }, busy, errors.fingerprint) Button( onClick = { onManual(input) }, @@ -128,6 +123,37 @@ private fun ManualPairingForm(busy: Boolean, onManual: (ManualPairingInput) -> U } } +@Composable +private fun PairingTokenField( + value: String, + onChange: (String) -> Unit, + busy: Boolean, + error: String? +) { + var visible by remember { mutableStateOf(false) } + OutlinedTextField( + value, + onChange, + label = { Text("Pairing token") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + enabled = !busy, + visualTransformation = if (visible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + trailingIcon = { + TextButton(onClick = { visible = !visible }, enabled = !busy) { + Text(if (visible) "Hide token" else "Show token") + } + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + isError = value.isNotEmpty() && error != null, + supportingText = { Text(error.takeIf { value.isNotEmpty() } ?: "Token from your desktop") } + ) +} + @Composable private fun FingerprintField( value: String, @@ -150,20 +176,33 @@ private fun FingerprintField( ) } +@Suppress("LongParameterList") // Trust review keeps each action and build availability explicit. @Composable fun PairingConfirmation( payload: PairingPayload, busy: Boolean, onProbe: () -> Unit, onConfirm: () -> Unit, - onCancel: () -> Unit + onCancel: () -> Unit, + tunnelAvailable: Boolean = Tailcat.available ) { AlertDialog( onDismissRequest = onCancel, title = { Text("Trust and connect?") }, text = { Column(Modifier.verticalScroll(rememberScrollState())) { - Text("Server: ${payload.hosts.joinToString()}:${payload.port}") + payload.hosts.forEach { host -> + Text("Direct: ${connectionEndpoint(host, payload.port).removeSuffix("/ws")}") + } + payload.tcAddress?.let { address -> + Text("Tailcat: $address") + Text("Port: ${payload.port}") + if (!tunnelAvailable) { + Text( + "Tailcat is unavailable in this build. Only direct routes will be used." + ) + } + } Text("Compare this certificate fingerprint with the fingerprint on your desktop.") Text( payload.certFingerprint ?: "Fetch the certificate before you trust this server." @@ -176,7 +215,9 @@ fun PairingConfirmation( enabled = !busy ) { Text( - if (payload.certFingerprint == + if (busy) { + "Checking certificate…" + } else if (payload.certFingerprint == null ) { "Fetch certificate" diff --git a/app/src/main/java/dev/intent/app/connection/RouteDiagnostics.kt b/app/src/main/java/dev/intent/app/connection/RouteDiagnostics.kt index 3c702e3..75197d1 100644 --- a/app/src/main/java/dev/intent/app/connection/RouteDiagnostics.kt +++ b/app/src/main/java/dev/intent/app/connection/RouteDiagnostics.kt @@ -1,6 +1,7 @@ package dev.intent.app.connection import android.util.Log +import dev.intent.core.protocol.WebSocketUpgradeException import dev.intent.tailcat.NativeTunnelException /** Only sanitized native errors may include text. Other errors expose classes only. */ @@ -17,6 +18,11 @@ internal object RouteDiagnostics { error?.let { " exception=${it.javaClass.simpleName}" + " cause=${it.cause?.javaClass?.simpleName}" + + ( + (it as? WebSocketUpgradeException)?.let { upgrade -> + " httpStatus=${upgrade.statusCode}" + } ?: "" + ) + ( (it as? NativeTunnelException)?.let { native -> " detail=${native.diagnosticDetail}" diff --git a/app/src/main/java/dev/intent/app/conversation/ConversationComposer.kt b/app/src/main/java/dev/intent/app/conversation/ConversationComposer.kt index 1e32777..516b3d8 100644 --- a/app/src/main/java/dev/intent/app/conversation/ConversationComposer.kt +++ b/app/src/main/java/dev/intent/app/conversation/ConversationComposer.kt @@ -11,8 +11,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Checkbox +import androidx.compose.material3.IconButton import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -21,10 +20,7 @@ import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState -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.Color @@ -38,7 +34,6 @@ import dev.intent.app.attachments.PhotoPicker import dev.intent.app.design.IntentButton import dev.intent.app.design.IntentSpacing import dev.intent.app.design.IntentTextButton as TextButton -import dev.intent.app.design.IntentTextField as OutlinedTextField import dev.intent.core.protocol.models.Question import dev.intent.core.protocol.models.QuestionOption @@ -61,20 +56,21 @@ fun ConversationComposer( questionPhotos: PhotoAttachmentStore? = null ) { val photoState = photos?.state?.collectAsState()?.value + val hasContent = draft.text.isNotBlank() || photoState?.photos?.isNotEmpty() == true Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Column(Modifier.heightIn(max = 240.dp).verticalScroll(rememberScrollState())) { + Column(Modifier.heightIn(max = 120.dp).verticalScroll(rememberScrollState())) { state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) } SendAttempts(state, onRetry, onCheckDelivery) QueuedConversationMessages(state, onRetract, onEditQueued, onSendNow) - state.questions?.let { - QuestionComposer( - it, - onAnswer, - onDismissQuestions, - state.dismissing != null, - questionPhotos - ) - } + } + state.questions?.let { + QuestionComposer( + it, + onAnswer, + onDismissQuestions, + state.dismissing != null, + questionPhotos + ) } photos?.let { PhotoAttachments(it) } Surface( @@ -103,7 +99,7 @@ fun ConversationComposer( ) photos?.let { PhotoPicker(it) } if (state.running) { - IntentButton( + IconButton( onClick = onStop, enabled = !state.stopping, modifier = Modifier.semantics { contentDescription = "Stop" } @@ -113,21 +109,17 @@ fun ConversationComposer( .background(LocalContentColor.current, RoundedCornerShape(2.dp)) ) } - } else { + } + if (!state.running || hasContent) { IntentButton( onClick = { if (onSend(draft.text) && clearOnSend) draft.text = "" }, - enabled = - ( - clearOnSend || - state.attempts.all { - it.status == SendStatus.ACCEPTED || - it.status == SendStatus.QUEUED - } - ) && - (photoState?.ready != false) && - (draft.text.isNotBlank() || photoState?.photos?.isNotEmpty() == true) + enabled = state.canSendDraft( + clearOnSend, + hasContent, + photoState?.ready != false + ) ) { Text("Send") } @@ -137,6 +129,17 @@ fun ConversationComposer( } } +private fun ConversationSendState.canSendDraft( + clearOnSend: Boolean, + hasContent: Boolean, + attachmentsReady: Boolean +): Boolean = hasContent && + attachmentsReady && + ( + clearOnSend || + attempts.all { it.status == SendStatus.ACCEPTED || it.status == SendStatus.QUEUED } + ) + @Composable private fun SendAttempts( state: ConversationSendState, @@ -172,96 +175,6 @@ private fun SendAttempts( } } -@Composable -private fun QuestionComposer( - set: QuestionSet, - onAnswer: (String, Map) -> Boolean, - onDismiss: (String) -> Unit, - dismissing: Boolean, - photos: PhotoAttachmentStore? -) { - val attachments = photos?.state?.collectAsState()?.value - var confirmingDismiss by remember(set.messageId) { mutableStateOf(false) } - var answers by remember(set.messageId) { mutableStateOf(emptyMap()) } - Column { - set.questions.forEach { question -> - val answer = answers[question.attachmentId] ?: QuestionAnswer() - QuestionInput(question, answer) { answers = answers + (question.attachmentId to it) } - } - photos?.let { - PhotoAttachments(it) - PhotoPicker(it) - } - Row { - TextButton( - onClick = { onAnswer(set.messageId, answers) }, - enabled = - !dismissing && attachments?.ready != false - ) { - Text("Send answers") - } - TextButton(onClick = { confirmingDismiss = true }, enabled = !dismissing) { - Text("Dismiss questions") - } - } - } - if (confirmingDismiss) { - AlertDialog( - onDismissRequest = { confirmingDismiss = false }, - title = { Text("Dismiss questions?") }, - text = { - Text( - "The questions will be dismissed without answers. They will not return after a reload." - ) - }, - confirmButton = { - TextButton(onClick = { - confirmingDismiss = false - onDismiss(set.messageId) - }, enabled = !dismissing) { Text("Dismiss") } - }, - dismissButton = { - TextButton(onClick = { confirmingDismiss = false }) { Text("Cancel") } - } - ) - } -} - -@Composable -private fun QuestionInput( - question: Question, - answer: QuestionAnswer, - onChange: (QuestionAnswer) -> Unit -) { - Column { - Text(question.header, style = MaterialTheme.typography.titleSmall) - Text(question.question) - question.explanation?.let { Text(it) } - question.options.forEach { option -> - Row { - Checkbox(option.label in answer.selected, { selected -> - val labels = when { - !selected -> answer.selected - option.label - question.multiSelect -> answer.selected + option.label - else -> listOf(option.label) - } - onChange(answer.copy(selected = labels, skipped = false)) - }) - Column { - Text(option.label) - option.description?.let { Text(it) } - } - } - } - OutlinedTextField(answer.other, { - onChange(answer.copy(other = it, skipped = false)) - }, label = { Text("Other") }) - TextButton(onClick = { onChange(QuestionAnswer(skipped = true)) }) { - Text(if (answer.skipped) "Skipped" else "Skip question") - } - } -} - @Suppress("UnusedPrivateMember") @Preview(showBackground = true) @Composable diff --git a/app/src/main/java/dev/intent/app/conversation/ConversationTranscript.kt b/app/src/main/java/dev/intent/app/conversation/ConversationTranscript.kt index fcd519d..dd83c24 100644 --- a/app/src/main/java/dev/intent/app/conversation/ConversationTranscript.kt +++ b/app/src/main/java/dev/intent/app/conversation/ConversationTranscript.kt @@ -2,6 +2,7 @@ package dev.intent.app.conversation import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -32,6 +33,7 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch @@ -138,7 +140,7 @@ internal fun ConversationTranscript( CircularProgressIndicator(Modifier.align(Alignment.TopCenter).size(24.dp)) } if (state.showsEmptyTranscript()) { - Text("No messages yet", Modifier.align(Alignment.Center)) + EmptyConversation(Modifier.align(Alignment.Center)) } state.error?.let { Text(it, Modifier.align(Alignment.TopCenter), color = MaterialTheme.colorScheme.error) @@ -156,6 +158,36 @@ internal fun ConversationTranscript( } } +@Composable +private fun EmptyConversation(modifier: Modifier = Modifier) { + val agent = dev.intent.app.agents.LocalConversationAgent.current + Column( + modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + agent?.name?.takeIf { it.isNotBlank() }?.let { + Text(it, style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center) + } + agent?.metadata.string("specialist")?.takeIf { it.isNotBlank() }?.let { + Text( + "Specialist · $it", + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center + ) + } + Text("No messages yet", color = MaterialTheme.colorScheme.onSurfaceVariant) + agent?.model?.takeIf { it.isNotBlank() }?.let { + Text( + "Using $it", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + } +} + private fun isDisjointReplacement(previous: Set, current: Set) = previous.isNotEmpty() && current.isNotEmpty() && previous.none { it in current } diff --git a/app/src/main/java/dev/intent/app/conversation/QuestionComposer.kt b/app/src/main/java/dev/intent/app/conversation/QuestionComposer.kt new file mode 100644 index 0000000..36dca48 --- /dev/null +++ b/app/src/main/java/dev/intent/app/conversation/QuestionComposer.kt @@ -0,0 +1,233 @@ +package dev.intent.app.conversation + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableIntStateOf +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.semantics.Role +import androidx.compose.ui.unit.dp +import dev.intent.app.attachments.PhotoAttachmentStore +import dev.intent.app.attachments.PhotoAttachments +import dev.intent.app.attachments.PhotoPicker +import dev.intent.app.design.IntentTextButton as TextButton +import dev.intent.app.design.IntentTextField as OutlinedTextField +import dev.intent.core.protocol.models.Question +import dev.intent.core.protocol.models.QuestionOption + +@Suppress("LongMethod") // Keeps answers and page state alive when the question body collapses. +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun QuestionComposer( + set: QuestionSet, + onAnswer: (String, Map) -> Boolean, + onDismiss: (String) -> Unit, + dismissing: Boolean, + photos: PhotoAttachmentStore? +) { + if (set.questions.isEmpty()) return + val attachments = photos?.state?.collectAsState()?.value + var confirmingDismiss by remember(set.messageId) { mutableStateOf(false) } + var answers by remember(set.messageId) { mutableStateOf(emptyMap()) } + var page by remember(set.messageId) { mutableIntStateOf(0) } + var expanded by remember(set.messageId) { mutableStateOf(true) } + var submitted by remember(set.messageId) { mutableStateOf(false) } + val index = page.coerceIn(set.questions.indices) + val question = set.questions[index] + val answer = answers[question.attachmentId] ?: QuestionAnswer() + val enabled = !dismissing && !submitted + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceContainer + ) { + Column(Modifier.fillMaxWidth().heightIn(max = 280.dp).padding(horizontal = 8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + TextButton(onClick = { expanded = !expanded }, modifier = Modifier.weight(1f)) { + Text( + if (expanded) { + "Question ${index + 1} of ${set.questions.size} · Hide" + } else { + "Questions · ${set.questions.size} · Show" + } + ) + } + IconButton(onClick = { confirmingDismiss = true }, enabled = enabled) { + Icon(Icons.Default.Close, contentDescription = "Dismiss questions") + } + } + if (expanded) { + key(question.attachmentId) { + Column( + Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()) + ) { + QuestionInput(question, answer, enabled) { + answers = answers + (question.attachmentId to it) + } + photos?.let { + PhotoAttachments(it) + PhotoPicker(it) + } + } + } + FlowRow( + horizontalArrangement = Arrangement.End, + modifier = Modifier.fillMaxWidth() + ) { + if (index > 0) { + TextButton(onClick = { + page = index - 1 + }, enabled = enabled) { Text("Back") } + } + TextButton(onClick = { + answers = + answers + (question.attachmentId to QuestionAnswer(skipped = true)) + if (index < set.questions.lastIndex) page = index + 1 + }, enabled = enabled) { + Text(if (answer.skipped) "Skipped" else "Skip question") + } + if (index < set.questions.lastIndex) { + TextButton(onClick = { + page = index + 1 + }, enabled = enabled) { Text("Next") } + } else { + TextButton(onClick = { + submitted = onAnswer(set.messageId, answers) + }, enabled = enabled && attachments?.ready != false) { + Text("Send answers") + } + } + } + } + } + } + if (confirmingDismiss) { + DismissQuestionsDialog( + enabled, + onCancel = { confirmingDismiss = false }, + onDismiss = { + confirmingDismiss = false + onDismiss(set.messageId) + } + ) + } +} + +@Composable +private fun DismissQuestionsDialog(enabled: Boolean, onCancel: () -> Unit, onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onCancel, + title = { Text("Dismiss questions?") }, + text = { + Text( + "The questions will be dismissed without answers. They will not return after a reload." + ) + }, + confirmButton = { + TextButton(onClick = onDismiss, enabled = enabled) { Text("Dismiss") } + }, + dismissButton = { TextButton(onClick = onCancel) { Text("Cancel") } } + ) +} + +@Composable +private fun QuestionInput( + question: Question, + answer: QuestionAnswer, + enabled: Boolean, + onChange: (QuestionAnswer) -> Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(question.header, style = MaterialTheme.typography.labelMedium) + Text(question.question, style = MaterialTheme.typography.titleSmall) + question.explanation?.let { Text(it, style = MaterialTheme.typography.bodySmall) } + Column(if (question.multiSelect) Modifier else Modifier.selectableGroup()) { + question.options.forEach { option -> + QuestionOptionRow( + option, + option.label in answer.selected, + question.multiSelect, + enabled + ) { + val labels = when { + !question.multiSelect -> listOf(option.label) + option.label in answer.selected -> answer.selected - option.label + else -> answer.selected + option.label + } + onChange(answer.copy(selected = labels, skipped = false)) + } + } + } + OutlinedTextField( + answer.other, + { onChange(answer.copy(other = it, skipped = false)) }, + modifier = Modifier.fillMaxWidth(), + enabled = enabled, + label = { Text("Other") }, + maxLines = 3 + ) + } +} + +@Composable +private fun QuestionOptionRow( + option: QuestionOption, + selected: Boolean, + multiSelect: Boolean, + enabled: Boolean, + onClick: () -> Unit +) { + val selection = if (multiSelect) { + Modifier.toggleable(selected, enabled = enabled, role = Role.Checkbox) { onClick() } + } else { + Modifier.selectable(selected, enabled = enabled, role = Role.RadioButton, onClick = onClick) + } + Row( + Modifier.fillMaxWidth().then(selection).heightIn(min = 48.dp).padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (multiSelect) { + Checkbox(selected, onCheckedChange = null, enabled = enabled) + } else { + RadioButton(selected, onClick = null, enabled = enabled) + } + Column(Modifier.weight(1f)) { + Text(option.label, style = MaterialTheme.typography.bodyMedium) + option.description?.let { + Text( + it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} diff --git a/app/src/main/java/dev/intent/app/conversation/QueuedConversationMessages.kt b/app/src/main/java/dev/intent/app/conversation/QueuedConversationMessages.kt index e4ac79a..3a33398 100644 --- a/app/src/main/java/dev/intent/app/conversation/QueuedConversationMessages.kt +++ b/app/src/main/java/dev/intent/app/conversation/QueuedConversationMessages.kt @@ -1,13 +1,25 @@ package dev.intent.app.conversation +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.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme 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.text.style.TextOverflow import dev.intent.app.design.IntentTextButton as TextButton import dev.intent.app.design.IntentTextField import dev.intent.core.protocol.models.QueuedMessage @@ -21,6 +33,9 @@ internal fun QueuedConversationMessages( ) { val (background, human) = state.queue.partition { ConversationNoise.label(it) != null } var expanded by remember { mutableStateOf(false) } + if (human.isNotEmpty()) { + Text("Queued messages · ${human.size}", style = MaterialTheme.typography.labelSmall) + } human.forEach { QueuedMessageRow(it, it.id !in state.retracting, onRetract, onEdit, onSendNow) } if (background.isNotEmpty()) { Column { @@ -45,6 +60,7 @@ private fun QueuedMessageRow( onSendNow: (String) -> Unit ) { var editing by remember(message.id) { mutableStateOf(false) } + var actions by remember(message.id) { mutableStateOf(false) } var text by remember(message.id, message.content) { mutableStateOf(message.content) } Column { if (editing) { @@ -57,15 +73,33 @@ private fun QueuedMessageRow( TextButton(onClick = { editing = false }) { Text("Cancel edit") } } } else { - Text("Queued: ${message.content}") - Row { - TextButton(onClick = { editing = true }, enabled = enabled) { Text("Edit") } - TextButton(onClick = { - onSendNow(message.id) - }, enabled = enabled) { Text("Send now") } - TextButton(onClick = { - onRetract(message.id) - }, enabled = enabled) { Text("Retract") } + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text( + message.content.ifBlank { "Queued message" }, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Box { + IconButton(onClick = { actions = true }, enabled = enabled) { + Icon(Icons.Default.MoreVert, contentDescription = "Queued message actions") + } + DropdownMenu(expanded = actions, onDismissRequest = { actions = false }) { + DropdownMenuItem(text = { Text("Edit") }, onClick = { + actions = false + editing = true + }, enabled = enabled) + DropdownMenuItem(text = { Text("Send now") }, onClick = { + actions = false + onSendNow(message.id) + }, enabled = enabled) + DropdownMenuItem(text = { Text("Retract") }, onClick = { + actions = false + onRetract(message.id) + }, enabled = enabled) + } + } } } } diff --git a/app/src/main/java/dev/intent/app/drawers/DrawerLists.kt b/app/src/main/java/dev/intent/app/drawers/DrawerLists.kt index ea0b553..fe9fac0 100644 --- a/app/src/main/java/dev/intent/app/drawers/DrawerLists.kt +++ b/app/src/main/java/dev/intent/app/drawers/DrawerLists.kt @@ -18,6 +18,8 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp @@ -30,6 +32,7 @@ import dev.intent.app.design.IntentScreen import dev.intent.app.design.IntentTextButton as TextButton import dev.intent.app.history.AgentHistoryStore import dev.intent.app.history.AgentVisit +import dev.intent.app.history.historyIdentity @Composable fun ConnectionsDrawer(state: ConnectionState, onSelect: (String) -> Unit, onManage: () -> Unit) { @@ -84,6 +87,7 @@ fun ConnectionsDrawer(state: ConnectionState, onSelect: (String) -> Unit, onMana } @Composable +@Suppress("LongMethod") // History header and connection-scoped selectable rows. fun AgentHistoryDrawer( store: AgentHistoryStore, state: ConnectionState, @@ -103,10 +107,15 @@ fun AgentHistoryDrawer( Row(Modifier.fillMaxWidth()) { Text( "Recent agents", - Modifier.weight(1f), + Modifier.weight(1f).semantics { heading() }, style = MaterialTheme.typography.titleMedium ) - if (visible.isNotEmpty()) TextButton(onClick = store::clear) { Text("Clear") } + if (visible.isNotEmpty()) { + TextButton( + onClick = store::clear, + modifier = Modifier.semantics { contentDescription = "Clear agent history" } + ) { Text("Clear") } + } } } if (visible.isEmpty()) item { Text("Agents you open on any device appear here.") } @@ -118,24 +127,23 @@ fun AgentHistoryDrawer( }.testTag("history.${visit.connectionId}.${visit.agentId}") .clickable { onSelect(visit) }.padding(vertical = IntentScreen.itemSpacing) ) { - val liveTitle = titles[visit.workspaceId].takeIf { - visit.connectionId == - state.activeId - } - Text(visit.agentName, style = MaterialTheme.typography.titleSmall) - if (current) CurrentAgentLabel() - Text( - listOfNotNull( - liveTitle ?: visit.workspaceTitle, - saved[visit.connectionId]?.displayName - ).joinToString(" · "), - color = IntentScreen.secondaryText, - style = MaterialTheme.typography.bodySmall + val identity = visit.historyIdentity( + state.activeId, + titles, + saved[visit.connectionId]?.displayName ) + Text(identity.title, style = MaterialTheme.typography.titleSmall) + if (identity.subtitle.isNotBlank()) { + Text( + identity.subtitle, + color = IntentScreen.secondaryText, + style = MaterialTheme.typography.bodySmall + ) + } Text( android.text.format.DateUtils.getRelativeTimeSpanString( visit.visitedAt - ).toString(), + ).toString() + if (current) " · Current agent" else "", style = MaterialTheme.typography.labelSmall, color = IntentScreen.secondaryText ) @@ -143,12 +151,3 @@ fun AgentHistoryDrawer( } } } - -@Composable -private fun CurrentAgentLabel() { - Text( - "Current agent", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurface - ) -} diff --git a/app/src/main/java/dev/intent/app/drawers/WorkspaceDrawers.kt b/app/src/main/java/dev/intent/app/drawers/WorkspaceDrawers.kt index 1137e57..95528c6 100644 --- a/app/src/main/java/dev/intent/app/drawers/WorkspaceDrawers.kt +++ b/app/src/main/java/dev/intent/app/drawers/WorkspaceDrawers.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.systemGestures import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.systemGestureExclusion import androidx.compose.material3.LocalContentColor import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -56,11 +57,14 @@ class DrawerPanels( val LocalDrawerPanels = staticCompositionLocalOf { null } val LocalTranscriptDrawerGesture = staticCompositionLocalOf { Modifier } + +/** Marks a page header where a horizontal swipe opens either drawer. */ +val LocalHeaderDrawerGesture = staticCompositionLocalOf { Modifier } val LocalCurrentHistoryAgent = staticCompositionLocalOf { null } val LocalDrawerButtons = staticCompositionLocalOf { null } class DrawerButtons(val connections: () -> Unit, val history: () -> Unit) -/** Physical left/right panels. Child row gestures win through normal pointer consumption. */ +/** Workspace edges and the page header reveal panels; row gestures retain the rest of the page. */ @Suppress("LongMethod", "CyclomaticComplexMethod") @Composable fun WorkspaceDrawers(historyOnly: Boolean = false, content: @Composable () -> Unit) { @@ -70,6 +74,7 @@ fun WorkspaceDrawers(historyOnly: Boolean = false, content: @Composable () -> Un return } var transcriptBounds by remember { mutableStateOf(null) } + var headerBounds by remember { mutableStateOf(null) } var drawerOrigin by remember { mutableStateOf(Offset.Zero) } var side by rememberSaveable { mutableStateOf(null) } var drag by remember { mutableFloatStateOf(0f) } @@ -139,15 +144,23 @@ fun WorkspaceDrawers(historyOnly: Boolean = false, content: @Composable () -> Un else -> null } val gesture = Modifier.pointerInput(side, width, leftInset, rightInset, historyOnly) { + var fromLeft = false + var fromHeader = false drawerGestures( canStart = { position -> - position.x > leftInset && - position.x < size.width - rightInset && - ( - !historyOnly || + if (!historyOnly) { + fromLeft = position.x <= edgeMargin + fromHeader = headerBounds?.contains(position + drawerOrigin) == true + val fromRight = position.x >= size.width - edgeMargin + side != null || fromLeft || fromHeader || fromRight + } else { + position.x > leftInset && + position.x < size.width - rightInset && + ( side != null || - transcriptBounds?.contains(position + drawerOrigin) == true - ) + transcriptBounds?.contains(position + drawerOrigin) == true + ) + } }, onCancel = { drag = 0f @@ -168,13 +181,33 @@ fun WorkspaceDrawers(historyOnly: Boolean = false, content: @Composable () -> Un drag = when (side) { "Connections" -> (drag + amount).coerceIn(-width, 0f) "Agent history" -> (drag + amount).coerceIn(0f, width) - else -> (drag + amount).coerceIn(-width, if (historyOnly) 0f else width) + else -> when { + !historyOnly && fromHeader -> (drag + amount).coerceIn(-width, width) + !historyOnly && fromLeft -> (drag + amount).coerceIn(0f, width) + else -> (drag + amount).coerceIn(-width, 0f) + } } } ) } Box( Modifier.fillMaxSize().background(IntentScreen.surface) + .then( + if (historyOnly) { + Modifier + } else { + Modifier.systemGestureExclusion { coordinates -> + val halfHeight = with(density) { 100.dp.toPx() } + val center = coordinates.size.height / 2f + Rect( + 0f, + center - halfHeight, + coordinates.size.width.toFloat(), + center + halfHeight + ) + } + } + ) .then(gesture) ) { if (visible != null) { @@ -210,6 +243,8 @@ fun WorkspaceDrawers(historyOnly: Boolean = false, content: @Composable () -> Un LocalContentColor provides IntentScreen.text, LocalTranscriptDrawerGesture provides Modifier.onGloballyPositioned { transcriptBounds = it.boundsInRoot() }, + LocalHeaderDrawerGesture provides + Modifier.onGloballyPositioned { headerBounds = it.boundsInRoot() }, LocalDrawerButtons provides DrawerButtons( { side = "Connections" }, { side = "Agent history" } diff --git a/app/src/main/java/dev/intent/app/files/NumberedTextView.kt b/app/src/main/java/dev/intent/app/files/NumberedTextView.kt new file mode 100644 index 0000000..ccacc99 --- /dev/null +++ b/app/src/main/java/dev/intent/app/files/NumberedTextView.kt @@ -0,0 +1,67 @@ +package dev.intent.app.files + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Typeface +import android.widget.TextView + +/** Draw only visible logical line numbers. The gutter is not part of copied text. */ +internal class NumberedTextView(context: Context) : TextView(context) { + private val gutterPaint = Paint(Paint.ANTI_ALIAS_FLAG) + private var starts = listOf(0) + private val inset = (TEXT_INSET_DP * resources.displayMetrics.density).toInt() + var gutterColor: Int = currentTextColor + var gutterBackground: Int = android.graphics.Color.TRANSPARENT + + init { + typeface = Typeface.MONOSPACE + textSize = TEXT_SIZE_SP + setTextIsSelectable(true) + isVerticalScrollBarEnabled = true + isHorizontalScrollBarEnabled = true + } + + fun setLineStarts(lineStarts: List) { + starts = lineStarts + val gutter = paint.measureText(starts.size.toString()).toInt() + inset * 2 + setPadding(gutter + inset, inset, inset, inset) + } + + override fun onDraw(canvas: Canvas) { + val gutterWidth = paddingLeft - inset + val saved = canvas.save() + canvas.clipRect(scrollX + gutterWidth, scrollY, scrollX + width, scrollY + height) + super.onDraw(canvas) + canvas.restoreToCount(saved) + val textLayout = layout ?: return + gutterPaint.color = gutterBackground + canvas.drawRect( + scrollX.toFloat(), + scrollY.toFloat(), + (scrollX + gutterWidth).toFloat(), + (scrollY + height).toFloat(), + gutterPaint + ) + gutterPaint.color = gutterColor + gutterPaint.typeface = typeface + gutterPaint.textSize = textSize + gutterPaint.textAlign = Paint.Align.RIGHT + val first = textLayout.getLineForVertical((scrollY - totalPaddingTop).coerceAtLeast(0)) + val last = textLayout.getLineForVertical(scrollY + height - totalPaddingTop) + for (line in first..last) { + val logical = starts.binarySearch(textLayout.getLineStart(line)) + if (logical >= 0) { + canvas.drawText( + (logical + 1).toString(), + (scrollX + gutterWidth - inset).toFloat(), + (totalPaddingTop + textLayout.getLineBaseline(line)).toFloat(), + gutterPaint + ) + } + } + } +} + +private const val TEXT_SIZE_SP = 16f +private const val TEXT_INSET_DP = 8 diff --git a/app/src/main/java/dev/intent/app/files/WorkspaceDocumentViewers.kt b/app/src/main/java/dev/intent/app/files/WorkspaceDocumentViewers.kt index bb712e9..b666ce4 100644 --- a/app/src/main/java/dev/intent/app/files/WorkspaceDocumentViewers.kt +++ b/app/src/main/java/dev/intent/app/files/WorkspaceDocumentViewers.kt @@ -39,9 +39,15 @@ internal fun WorkspacePdfPreview(file: File, modifier: Modifier = Modifier) { count = result.getOrNull() failed = result.isFailure } - if (failed) Text("Cannot open PDF. Use Open in another app.") - LazyColumn(modifier.fillMaxSize().testTag("file.pdf")) { - items(count ?: 0) { index -> PdfPage(file, index) } + Column(modifier.fillMaxSize()) { + Text( + "This preview shows page images. For screen-reader text, search, and selection, " + + "open this PDF in another app that supports accessible PDF text." + ) + if (failed) Text("Cannot open PDF. Try an external PDF reader.") + LazyColumn(Modifier.weight(1f).testTag("file.pdf")) { + items(count ?: 0) { index -> PdfPage(file, index) } + } } } @@ -72,7 +78,9 @@ private fun PdfPage(file: File, index: Int) { } Column(Modifier.height(PDF_PAGE_HEIGHT.dp)) { Text("Page ${index + 1}") - bitmap?.let { WorkspaceImagePreview(it, Modifier.weight(1f)) } + bitmap?.let { + WorkspaceImagePreview(it, Modifier.weight(1f), "PDF page ${index + 1}, image preview") + } if (failed) Text("Cannot render this page.") } } diff --git a/app/src/main/java/dev/intent/app/files/WorkspaceFilePreviewRoute.kt b/app/src/main/java/dev/intent/app/files/WorkspaceFilePreviewRoute.kt index c961ecd..8bd1cbd 100644 --- a/app/src/main/java/dev/intent/app/files/WorkspaceFilePreviewRoute.kt +++ b/app/src/main/java/dev/intent/app/files/WorkspaceFilePreviewRoute.kt @@ -52,7 +52,7 @@ fun WorkspaceFilePreviewRoute(source: WorkspaceFileSource, path: String, onBack: } internal sealed interface FilePreviewContent { - data class TextFile(val text: String) : FilePreviewContent + data class TextFile(val text: String, val path: String = "") : FilePreviewContent data class ImageFile(val bitmap: Bitmap) : FilePreviewContent data class PdfFile(val file: File) : FilePreviewContent data class VideoFile(val file: File) : FilePreviewContent @@ -96,12 +96,10 @@ private fun FilePreviewBody(source: WorkspaceFileSource, path: String, modifier: } Column(modifier.fillMaxSize()) { state.file?.let { file -> - TextButton(onClick = { - runCatching { - openMedia(context, file, MediaReference(file.name, false, path = path)) - } - .onFailure { openError = "No app can open this file." } - }) { Text("Open in another app") } + OpenPreviewButton(file, path, state.content is FilePreviewContent.PdfFile) { + openError = + it + } } openError?.let { Text(it, color = IntentScreen.error) } if (state.content is FilePreviewContent.ImageFile) { @@ -116,6 +114,33 @@ private fun FilePreviewBody(source: WorkspaceFileSource, path: String, modifier: } } +@Composable +private fun OpenPreviewButton( + file: File, + path: String, + isPdf: Boolean, + onError: (String?) -> Unit +) { + val context = LocalContext.current + TextButton(onClick = { + onError(null) + runCatching { + openMedia( + context, + file, + MediaReference( + file.name, + false, + path = path, + mimeType = if (isPdf) "application/pdf" else null + ) + ) + }.onFailure { onError("No app can open this file.") } + }) { + Text(if (isPdf) "Open PDF in another app" else "Open in another app") + } +} + @Composable internal fun ColumnScope.PreviewContent(state: PreviewState, onRetry: () -> Unit) { when (val content = state.content) { @@ -124,7 +149,8 @@ internal fun ColumnScope.PreviewContent(state: PreviewState, onRetry: () -> Unit is FilePreviewContent.HtmlFile -> WorkspaceHtmlPreview(content.text, Modifier.weight(1f)) is FilePreviewContent.TextFile -> WorkspaceTextPreview( content.text, - Modifier.weight(1f) + Modifier.weight(1f), + path = content.path ) is FilePreviewContent.ImageFile -> WorkspaceImagePreview( content.bitmap, @@ -165,7 +191,7 @@ internal fun previewContent(file: File, kind: WorkspacePreviewKind): FilePreview decodePhoto(file, IMAGE_MAX_EDGE) ) WorkspacePreviewKind.TEXT -> runCatching { - FilePreviewContent.TextFile(readWorkspaceText(file)) + FilePreviewContent.TextFile(readWorkspaceText(file), file.name) } .getOrElse { FilePreviewContent.Unsupported } WorkspacePreviewKind.UNSUPPORTED -> FilePreviewContent.Unsupported diff --git a/app/src/main/java/dev/intent/app/files/WorkspaceFileViewers.kt b/app/src/main/java/dev/intent/app/files/WorkspaceFileViewers.kt index 72dba6f..355b9b2 100644 --- a/app/src/main/java/dev/intent/app/files/WorkspaceFileViewers.kt +++ b/app/src/main/java/dev/intent/app/files/WorkspaceFileViewers.kt @@ -1,9 +1,9 @@ package dev.intent.app.files import android.graphics.Bitmap -import android.graphics.Typeface -import android.text.method.ScrollingMovementMethod -import android.widget.TextView +import android.text.SpannableString +import android.text.Spanned +import android.text.style.ForegroundColorSpan import androidx.compose.foundation.Image import androidx.compose.foundation.gestures.rememberTransformableState import androidx.compose.foundation.gestures.transformable @@ -11,12 +11,14 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf 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 @@ -27,33 +29,68 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.testTag import androidx.compose.ui.viewinterop.AndroidView -import dev.intent.app.design.IntentScreen import dev.intent.app.design.IntentTextButton as TextButton -/** Native selectable text wraps to the viewport, without a giant Compose paragraph. */ +/** Selectable native text, bounded layout, and a gutter that stays out of copied text. */ @Composable -internal fun WorkspaceTextPreview(text: String, modifier: Modifier = Modifier) { - val color = IntentScreen.text.toArgb() - AndroidView( - modifier = modifier.fillMaxSize().testTag("file.text"), - factory = { context -> - TextView(context).apply { - typeface = Typeface.MONOSPACE - textSize = TEXT_SIZE_SP - setTextIsSelectable(true) - setHorizontallyScrolling(false) - movementMethod = ScrollingMovementMethod() - setPadding(TEXT_PADDING, TEXT_PADDING, TEXT_PADDING, TEXT_PADDING) - setTextColor(color) - this.text = text +internal fun WorkspaceTextPreview(text: String, modifier: Modifier = Modifier, path: String = "") { + var wrap by rememberSaveable(path) { mutableStateOf(true) } + val content = remember(text) { boundedWorkspaceText(text) } + val tokens = remember(content, path) { workspaceCodeTokens(content.text, path) } + val colors = MaterialTheme.colorScheme + val styled = remember(tokens, colors) { + SpannableString(content.text).apply { + var offset = 0 + tokens.forEach { token -> + val color = when (token.kind) { + "string" -> colors.onTertiaryContainer + "number" -> colors.secondary + "comment" -> colors.onSurfaceVariant + else -> colors.onSurface + } + setSpan( + ForegroundColorSpan(color.toArgb()), + offset, + offset + token.text.length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + offset += token.text.length } - }, - update = { it.setTextColor(color) } - ) + } + } + Column(modifier.fillMaxSize()) { + TextButton(onClick = { wrap = !wrap }, modifier = Modifier.testTag("file.wrap")) { + Text(if (wrap) "Line wrap: on" else "Line wrap: off") + } + if (content.truncated) { + Text("Preview shortened for performance. Open in another app to read the full file.") + } + AndroidView( + modifier = Modifier.weight(1f).fillMaxSize().testTag("file.text"), + factory = { context -> NumberedTextView(context) }, + update = { + if (it.tag !== styled) { + it.text = styled + it.tag = styled + } + it.setLineStarts(content.lineStarts) + it.setTextColor(colors.onSurface.toArgb()) + it.gutterColor = colors.onSurfaceVariant.toArgb() + it.gutterBackground = colors.background.toArgb() + it.setHorizontallyScrolling(!wrap) + if (wrap && it.scrollX != 0) it.scrollTo(0, it.scrollY) + it.invalidate() + } + ) + } } @Composable -internal fun WorkspaceImagePreview(bitmap: Bitmap, modifier: Modifier = Modifier) { +internal fun WorkspaceImagePreview( + bitmap: Bitmap, + modifier: Modifier = Modifier, + description: String = "File image" +) { var scale by remember(bitmap) { mutableFloatStateOf(1f) } var offset by remember(bitmap) { mutableStateOf(Offset.Zero) } val transform = rememberTransformableState { zoom, pan, _ -> @@ -81,7 +118,7 @@ internal fun WorkspaceImagePreview(bitmap: Bitmap, modifier: Modifier = Modifier ) { Image( bitmap.asImageBitmap(), - "File image", + description, Modifier.fillMaxSize().testTag("file.image").graphicsLayer { scaleX = scale scaleY = scale @@ -95,5 +132,3 @@ internal fun WorkspaceImagePreview(bitmap: Bitmap, modifier: Modifier = Modifier private const val MAX_ZOOM = 8f private const val ZOOM_STEP = 2f -private const val TEXT_SIZE_SP = 16f -private const val TEXT_PADDING = 16 diff --git a/app/src/main/java/dev/intent/app/files/WorkspaceTextContent.kt b/app/src/main/java/dev/intent/app/files/WorkspaceTextContent.kt new file mode 100644 index 0000000..c5a92be --- /dev/null +++ b/app/src/main/java/dev/intent/app/files/WorkspaceTextContent.kt @@ -0,0 +1,36 @@ +package dev.intent.app.files + +import dev.intent.app.markdown.CodeToken +import dev.intent.app.markdown.codeTokens + +internal data class WorkspaceTextContent(val text: String, val truncated: Boolean) { + val lineStarts: List = buildList { + add(0) + text.forEachIndexed { index, char -> if (char == '\n') add(index + 1) } + } +} + +/** Bound native layout as well as downloads; never cut a UTF-16 surrogate pair. */ +internal fun boundedWorkspaceText(text: String): WorkspaceTextContent { + var end = minOf(text.length, TEXT_LAYOUT_CHAR_LIMIT) + var lines = 1 + for (index in 0 until end) { + if (text[index] == '\n' && ++lines > TEXT_LAYOUT_LINE_LIMIT) { + end = index + break + } + } + if (end < text.length && end > 0 && text[end - 1].isHighSurrogate()) end-- + return WorkspaceTextContent(text.substring(0, end), end < text.length) +} + +internal fun workspaceCodeTokens(text: String, path: String): List = + if (text.length <= TEXT_HIGHLIGHT_CHAR_LIMIT) { + codeTokens(text, path.substringAfterLast('.', "")) + } else { + listOf(CodeToken(text)) + } + +internal const val TEXT_LAYOUT_CHAR_LIMIT = 200_000 +internal const val TEXT_LAYOUT_LINE_LIMIT = 4_000 +internal const val TEXT_HIGHLIGHT_CHAR_LIMIT = 20_000 diff --git a/app/src/main/java/dev/intent/app/git/GitCommitScreen.kt b/app/src/main/java/dev/intent/app/git/GitCommitScreen.kt index 94eb769..c6f35b2 100644 --- a/app/src/main/java/dev/intent/app/git/GitCommitScreen.kt +++ b/app/src/main/java/dev/intent/app/git/GitCommitScreen.kt @@ -1,13 +1,16 @@ package dev.intent.app.git import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -15,8 +18,15 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog import dev.intent.app.design.IntentScreen import dev.intent.app.design.IntentTextButton as TextButton +import dev.intent.app.design.IntentTextField import dev.intent.app.navigation.CompactAppBar import dev.intent.core.protocol.models.GitCommit import dev.intent.core.protocol.models.GitCommitFile @@ -35,8 +45,7 @@ internal fun GitCommitScreen( verticalArrangement = Arrangement.spacedBy(IntentScreen.itemSpacing) ) { item { - Text(commit.message) - Text("${commit.author} · ${commit.sha}", color = IntentScreen.secondaryText) + CommitMetadata(commit) } item { if (state.commitLoading) CircularProgressIndicator() @@ -62,20 +71,79 @@ internal fun GitCommitScreen( } } +@Suppress("DEPRECATION") // Same clipboard API as existing connection details. +@Composable +private fun CommitMetadata(commit: GitCommit) { + val clipboard = LocalClipboardManager.current + var copied by remember(commit.hash) { mutableStateOf(false) } + SelectionContainer { + Column(verticalArrangement = Arrangement.spacedBy(IntentScreen.itemSpacing)) { + Text(commit.message) + Text(commit.author.ifBlank { "Unknown author" }, color = IntentScreen.secondaryText) + Text(commitDateTime(commit.date), color = IntentScreen.secondaryText) + commitPushLabel(commit.isPushed)?.let { Text(it, color = IntentScreen.secondaryText) } + Text(commit.hash, fontFamily = FontFamily.Monospace) + } + } + TextButton( + onClick = { + clipboard.setText(AnnotatedString(commit.hash)) + copied = true + }, + modifier = Modifier.testTag("commit.copy") + ) { + Text(if (copied) "Copied commit hash" else "Copy full commit hash") + } +} + @Composable private fun CommitFilePicker(state: GitState, onFile: (GitCommitFile) -> Unit) { var expanded by remember(state.selectedCommit?.hash) { mutableStateOf(false) } Text("File (${state.commitFiles.size})") - Box { - TextButton(onClick = { expanded = true }) { Text(state.selected?.path.orEmpty()) } - DropdownMenu(expanded, onDismissRequest = { expanded = false }) { - state.commitFiles.forEach { file -> - DropdownMenuItem(text = { - Text("${file.path} (+${file.additions} −${file.deletions})") - }, onClick = { - expanded = false - onFile(file) - }) + TextButton(onClick = { expanded = true }) { + Text(state.selected?.path ?: "Choose a file") + } + if (expanded) { + CommitFileDialog(state.commitFiles, onDismiss = { expanded = false }) { + expanded = false + onFile(it) + } + } +} + +@Composable +private fun CommitFileDialog( + files: List, + onDismiss: () -> Unit, + onFile: (GitCommitFile) -> Unit +) { + var query by remember { mutableStateOf("") } + val matches = remember(files, query) { filterCommitFiles(files, query) } + Dialog(onDismissRequest = onDismiss) { + Surface(color = IntentScreen.surface) { + Column( + Modifier.fillMaxWidth().heightIn( + max = FILE_PICKER_MAX_HEIGHT.dp + ).padding(IntentScreen.pageInsets), + verticalArrangement = Arrangement.spacedBy(IntentScreen.itemSpacing) + ) { + Text("Changed files") + IntentTextField( + query, + { query = it }, + Modifier.fillMaxWidth(), + label = { Text("Search changed files") }, + singleLine = true + ) + if (matches.isEmpty()) Text("No matching files") + LazyColumn(Modifier.weight(1f, fill = false)) { + items(matches, key = { it.path }) { file -> + TextButton(onClick = { onFile(file) }, modifier = Modifier.fillMaxWidth()) { + Text("${file.path} (+${file.additions} −${file.deletions})") + } + } + } + TextButton(onClick = onDismiss) { Text("Close file list") } } } } @@ -94,3 +162,5 @@ private fun CommitDiffStatus(state: GitState, onFile: (GitCommitFile) -> Unit) { Text("No text changes") } } + +private const val FILE_PICKER_MAX_HEIGHT = 520 diff --git a/app/src/main/java/dev/intent/app/git/GitPresentation.kt b/app/src/main/java/dev/intent/app/git/GitPresentation.kt index 811e207..93c653b 100644 --- a/app/src/main/java/dev/intent/app/git/GitPresentation.kt +++ b/app/src/main/java/dev/intent/app/git/GitPresentation.kt @@ -1,6 +1,7 @@ package dev.intent.app.git import androidx.compose.runtime.staticCompositionLocalOf +import dev.intent.core.protocol.models.GitCommitFile import dev.intent.core.protocol.models.Workspace import java.time.Instant import java.time.ZoneId @@ -38,3 +39,34 @@ internal fun commitDate( } catch (_: DateTimeParseException) { "Date unavailable" } + +internal fun commitDateTime( + value: String, + zone: ZoneId = ZoneId.systemDefault(), + locale: Locale = Locale.getDefault() +): String = try { + Instant.parse(value).atZone(zone).format( + DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT) + .withLocale(locale) + ) +} catch (_: DateTimeParseException) { + value.ifBlank { "Date unavailable" } +} + +/** Missing push metadata must never be presented as a local/unpushed commit. */ +internal fun commitPushLabel(isPushed: Boolean?): String? = when (isPushed) { + true -> "Pushed" + false -> "Not pushed" + null -> null +} + +internal fun filterCommitFiles(files: List, query: String): List { + val search = query.trim() + return if (search.isEmpty()) { + files + } else { + files.filter { + it.path.contains(search, ignoreCase = true) + } + } +} diff --git a/app/src/main/java/dev/intent/app/git/GitScreen.kt b/app/src/main/java/dev/intent/app/git/GitScreen.kt index a90117e..88af0c7 100644 --- a/app/src/main/java/dev/intent/app/git/GitScreen.kt +++ b/app/src/main/java/dev/intent/app/git/GitScreen.kt @@ -180,6 +180,9 @@ private fun CommitRow(commit: GitCommit) { style = MaterialTheme.typography.bodySmall ) } + commitPushLabel(commit.isPushed)?.let { + Text(it, color = IntentScreen.secondaryText, style = MaterialTheme.typography.bodySmall) + } } } diff --git a/app/src/main/java/dev/intent/app/history/AgentHistoryIdentity.kt b/app/src/main/java/dev/intent/app/history/AgentHistoryIdentity.kt new file mode 100644 index 0000000..2353213 --- /dev/null +++ b/app/src/main/java/dev/intent/app/history/AgentHistoryIdentity.kt @@ -0,0 +1,19 @@ +package dev.intent.app.history + +internal data class AgentHistoryIdentity(val title: String, val subtitle: String) + +/** Workspace IDs and their live titles belong to the active connection only. */ +internal fun AgentVisit.historyIdentity( + activeConnectionId: String?, + liveTitles: Map, + deviceName: String? +): AgentHistoryIdentity { + val workspace = liveTitles[workspaceId] + ?.takeIf { connectionId == activeConnectionId && it.isNotBlank() } + ?: workspaceTitle?.takeIf { it.isNotBlank() } + return AgentHistoryIdentity( + title = workspace ?: agentName, + subtitle = listOfNotNull(deviceName, agentName.takeIf { workspace != null }) + .filter { it.isNotBlank() }.joinToString(" · ") + ) +} diff --git a/app/src/main/java/dev/intent/app/lists/SearchableList.kt b/app/src/main/java/dev/intent/app/lists/SearchableList.kt index e9b4b54..86d9ea0 100644 --- a/app/src/main/java/dev/intent/app/lists/SearchableList.kt +++ b/app/src/main/java/dev/intent/app/lists/SearchableList.kt @@ -22,12 +22,16 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect 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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController @@ -46,12 +50,19 @@ internal fun SearchableList( currentOnly: Boolean? = null, onCurrentOnly: (Boolean) -> Unit = {}, launcher: dev.intent.app.navigation.LauncherActions? = null, + controls: (@Composable (Boolean, () -> Unit) -> Unit)? = null, content: @Composable (PaddingValues) -> Unit ) { var searching by rememberSaveable { mutableStateOf(false) } val keyboardVisible = WindowInsets.ime.getBottom(LocalDensity.current) > 0 + val showFloatingControls = !searching && !keyboardVisible + val floatingSearch = controls == null && hasData val keyboard = LocalSoftwareKeyboardController.current val focus = LocalFocusManager.current + val searchFocus = remember { FocusRequester() } + LaunchedEffect(searching) { + if (searching) searchFocus.requestFocus() + } val close: () -> Unit = { onQuery("") searching = false @@ -61,6 +72,9 @@ internal fun SearchableList( BackHandler(searching, onBack = close) Box(Modifier.fillMaxSize()) { Column { + controls?.invoke(searching) { + if (searching) close() else searching = true + } if (searching) { OutlinedTextField( value = query, @@ -76,19 +90,10 @@ internal fun SearchableList( IconButton(onClick = close) { Icon(Icons.Default.Close, "Clear search") } }, modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp) + .focusRequester(searchFocus) ) } - if (currentOnly != null) { - Row( - Modifier.padding(horizontal = 16.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - FilterChip(currentOnly, { - onCurrentOnly(true) - }, label = { Text("Current") }) - FilterChip(!currentOnly, { onCurrentOnly(false) }, label = { Text("All") }) - } - } + CurrentListFilter(currentOnly, onCurrentOnly) Box(Modifier.weight(1f)) { content( PaddingValues( @@ -98,22 +103,22 @@ internal fun SearchableList( bottom = listBottomInset( searching || keyboardVisible, launcher != null, - hasData + floatingSearch ) ) ) } } - if (!searching && hasData && !keyboardVisible) { + if (showFloatingControls && floatingSearch) { FloatingActionButton( onClick = { searching = true }, modifier = Modifier.align(Alignment.BottomEnd).padding( end = 16.dp, - bottom = if (launcher == null) 16.dp else 88.dp + bottom = if (launcher == null) 16.dp else 112.dp ) ) { Icon(Icons.Default.Search, label) } } - if (!searching && launcher != null && !keyboardVisible) { + if (showFloatingControls && launcher != null) { dev.intent.app.navigation.WorkspaceActionLauncher( launcher, Modifier.align(Alignment.BottomEnd).padding(16.dp) @@ -122,9 +127,23 @@ internal fun SearchableList( } } +@Composable +private fun CurrentListFilter(currentOnly: Boolean?, onCurrentOnly: (Boolean) -> Unit) { + if (currentOnly != null) { + Row( + Modifier.padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + FilterChip(currentOnly, { onCurrentOnly(true) }, label = { Text("Current") }) + FilterChip(!currentOnly, { onCurrentOnly(false) }, label = { Text("All") }) + } + } +} + private fun listBottomInset(searching: Boolean, launcher: Boolean, hasData: Boolean) = when { searching -> 16.dp - launcher && hasData -> 160.dp - launcher || hasData -> 88.dp + launcher && hasData -> 184.dp + launcher -> 112.dp + hasData -> 88.dp else -> 16.dp } diff --git a/app/src/main/java/dev/intent/app/markdown/MarkdownContent.kt b/app/src/main/java/dev/intent/app/markdown/MarkdownContent.kt index 468ae2d..d73f69a 100644 --- a/app/src/main/java/dev/intent/app/markdown/MarkdownContent.kt +++ b/app/src/main/java/dev/intent/app/markdown/MarkdownContent.kt @@ -52,6 +52,10 @@ val LocalMarkdownFenceRenderer = { language, code -> MermaidFence(language, code) } } +/** Notes can replace a list item in place; ordinary Markdown remains read-only. */ +val LocalMarkdownListItemRenderer = + staticCompositionLocalOf<(@Composable (ListItem) -> Boolean)?> { null } + @Composable fun MarkdownContent(content: String, modifier: Modifier = Modifier) { val document = remember(content) { parseMarkdown(content) } @@ -103,6 +107,9 @@ fun MarkdownNode(node: Node) { private fun MarkdownList(node: Node, start: Int?) { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { node.children().filterIsInstance().forEachIndexed { index, item -> + if (LocalMarkdownListItemRenderer.current?.invoke(item) == true) { + return@forEachIndexed + } val marker = item.children().filterIsInstance().firstOrNull() Row { if (marker != null) { diff --git a/app/src/main/java/dev/intent/app/markdown/MarkdownDocument.kt b/app/src/main/java/dev/intent/app/markdown/MarkdownDocument.kt index 82864e8..4e12c32 100644 --- a/app/src/main/java/dev/intent/app/markdown/MarkdownDocument.kt +++ b/app/src/main/java/dev/intent/app/markdown/MarkdownDocument.kt @@ -4,14 +4,20 @@ import java.net.URI import org.commonmark.ext.gfm.tables.TablesExtension import org.commonmark.ext.task.list.items.TaskListItemsExtension import org.commonmark.node.Node +import org.commonmark.parser.IncludeSourceSpans import org.commonmark.parser.Parser private val parser = Parser.builder().extensions( listOf(TablesExtension.create(), TaskListItemsExtension.create()) ).build() +private val positionedParser = Parser.builder().extensions( + listOf(TablesExtension.create(), TaskListItemsExtension.create()) +).includeSourceSpans(IncludeSourceSpans.BLOCKS).build() + /** Each render owns its tree, including partial fences received during streaming. */ -fun parseMarkdown(source: String): Node = parser.parse(source) +fun parseMarkdown(source: String, sourcePositions: Boolean = false): Node = + (if (sourcePositions) positionedParser else parser).parse(source) fun Node.children(): List = generateSequence(firstChild) { it.next }.toList() diff --git a/app/src/main/java/dev/intent/app/navigation/CompactAppBar.kt b/app/src/main/java/dev/intent/app/navigation/CompactAppBar.kt index 4eb6a4b..3979cda 100644 --- a/app/src/main/java/dev/intent/app/navigation/CompactAppBar.kt +++ b/app/src/main/java/dev/intent/app/navigation/CompactAppBar.kt @@ -45,8 +45,6 @@ fun CompactAppBar( leading: (@Composable () -> Unit)? = null, trailing: (@Composable () -> Unit)? = null ) { - var expanded by remember(title) { mutableStateOf(false) } - val menu = actions Row( Modifier.fillMaxWidth().heightIn(min = 56.dp).padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically @@ -73,26 +71,32 @@ fun CompactAppBar( overflow = TextOverflow.Ellipsis ) trailing?.invoke() - if (menu.isNotEmpty()) { - Box { - IconButton( - onClick = { expanded = true }, - modifier = Modifier.size(48.dp).semantics { - contentDescription = "More actions" - } - .background(MaterialTheme.colorScheme.surface, CircleShape) - ) { Icon(Icons.Default.MoreVert, contentDescription = null) } - DropdownMenu(expanded, onDismissRequest = { expanded = false }) { - menu.forEach { action -> - DropdownMenuItem( - text = { ActionLabel(action) }, - enabled = action.enabled, - onClick = { - expanded = false - action.run() - } - ) - } + ChromeActionsMenu(actions) + } +} + +@Composable +internal fun ChromeActionsMenu(actions: List) { + var expanded by remember { mutableStateOf(false) } + if (actions.isNotEmpty()) { + Box { + IconButton( + onClick = { expanded = true }, + modifier = Modifier.size(48.dp).semantics { + contentDescription = "More actions" + } + .background(MaterialTheme.colorScheme.surface, CircleShape) + ) { Icon(Icons.Default.MoreVert, contentDescription = null) } + DropdownMenu(expanded, onDismissRequest = { expanded = false }) { + actions.forEach { action -> + DropdownMenuItem( + text = { ActionLabel(action) }, + enabled = action.enabled, + onClick = { + expanded = false + action.run() + } + ) } } } diff --git a/app/src/main/java/dev/intent/app/navigation/HostAuthorityStore.kt b/app/src/main/java/dev/intent/app/navigation/HostAuthorityStore.kt new file mode 100644 index 0000000..79cb7cc --- /dev/null +++ b/app/src/main/java/dev/intent/app/navigation/HostAuthorityStore.kt @@ -0,0 +1,37 @@ +package dev.intent.app.navigation + +import java.io.Closeable +import java.util.concurrent.CompletableFuture +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +internal class HostAuthorityStore(private val requestOwner: () -> CompletableFuture) : + Closeable { + private val mutableOwner = MutableStateFlow(false) + val owner = mutableOwner.asStateFlow() + private var generation = 0L + private var closed = false + + @Synchronized + fun setConnected(connected: Boolean) { + if (closed) return + val token = ++generation + mutableOwner.value = false + if (connected) { + requestOwner().whenComplete { owner, error -> + synchronized(this) { + if (!closed && generation == token) { + mutableOwner.value = error == null && owner == true + } + } + } + } + } + + @Synchronized + override fun close() { + closed = true + generation++ + mutableOwner.value = false + } +} diff --git a/app/src/main/java/dev/intent/app/navigation/IntentLauncherButton.kt b/app/src/main/java/dev/intent/app/navigation/IntentLauncherButton.kt new file mode 100644 index 0000000..fa5d297 --- /dev/null +++ b/app/src/main/java/dev/intent/app/navigation/IntentLauncherButton.kt @@ -0,0 +1,67 @@ +@file:Suppress("MagicNumber") // Geometry and glyph transforms from iOS AssistantLaunchButton. + +package dev.intent.app.navigation + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import dev.intent.app.R + +@Composable +internal fun IntentLauncherButton(progress: Float, open: Boolean, onClick: () -> Unit) { + val fraction = progress.coerceIn(0f, 1f) + Surface( + onClick = onClick, + modifier = Modifier.size((80 - 32 * fraction).dp).semantics { + contentDescription = if (open) "Close app launcher" else "Open app launcher" + }, + shape = RoundedCornerShape((40 - 20 * fraction).dp), + color = lerp( + MaterialTheme.colorScheme.primary, + MaterialTheme.colorScheme.surfaceContainerHighest, + fraction + ), + contentColor = lerp( + MaterialTheme.colorScheme.onPrimary, + MaterialTheme.colorScheme.onSurface, + fraction + ), + shadowElevation = (10 * (1 - fraction)).dp + ) { + Box(contentAlignment = Alignment.Center) { + val glyph = Modifier.size((22 + 2 * fraction).dp) + Icon( + painterResource(R.drawable.intent_dandelion), + null, + glyph.graphicsLayer { + rotationZ = 90 * fraction + scaleX = 1 - 0.4f * fraction + scaleY = scaleX + alpha = 1 - fraction + } + ) + Icon( + painterResource(R.drawable.intent_close), + null, + glyph.graphicsLayer { + rotationZ = -90 * (1 - fraction) + scaleX = 0.6f + 0.4f * fraction + scaleY = scaleX + alpha = fraction + } + ) + } + } +} diff --git a/app/src/main/java/dev/intent/app/navigation/IntentNavigation.kt b/app/src/main/java/dev/intent/app/navigation/IntentNavigation.kt index 3f0e4be..2ef89b8 100644 --- a/app/src/main/java/dev/intent/app/navigation/IntentNavigation.kt +++ b/app/src/main/java/dev/intent/app/navigation/IntentNavigation.kt @@ -119,14 +119,17 @@ fun IntentNavigation( val creationPhotos = remember(session) { PhotoAttachmentStore(CreationMediaSource(context, session)) } var assistant by rememberSaveable(session) { mutableStateOf(false) } - var assistantOwner by remember(session) { mutableStateOf(false) } - LaunchedEffect(session) { - session.request("principal.me").whenComplete { principal, error -> - assistantOwner = - error == null && + val connectionReady = LocalConnectionReady.current + val authority = remember(session) { + HostAuthorityStore { + session.request("principal.me").thenApply { principal -> dev.intent.app.assistant.assistantOwner(principal, session.serverCapabilities) + } } } + val assistantOwner by authority.owner.collectAsState() + LaunchedEffect(authority, connectionReady) { authority.setConnected(connectionReady) } + DisposableEffect(authority) { onDispose { authority.close() } } val selected = selectedWorkspace val back = { if (!operationBusy) { @@ -138,8 +141,11 @@ fun IntentNavigation( } } } - BackHandler(selected != null || creating || assistant, onBack = back) - val workspaceAction = ChromeAction("Transfer workspace", !operationBusy) { transferring = true } + BackHandler(selected != null || creating || assistant || transferring, onBack = back) + val canTransfer = connectionReady && assistantOwner + val workspaceAction = ChromeAction("Transfer workspace", !operationBusy && canTransfer) { + transferring = true + } val selectedRow = workspaceState.workspaces.firstOrNull { it.id == selected } val lifecycleActions = selectedRow?.let { workspace -> WorkspaceAction.entries.map { action -> @@ -155,6 +161,15 @@ fun IntentNavigation( }.orEmpty() WorkspaceActionDialog(lifecycleState, lifecycle) CompositionLocalProvider( + LocalOpenWorkspaceAgent provides { workspaceId, agentId -> + historySelection += 1 + historyAgent = agentId + openRecent = false + selectedWorkspace = workspaceId + assistant = false + creating = false + transferring = false + }, dev.intent.app.assistant.LocalOpenProposalWorkspace provides { id -> selectedWorkspace = id assistant = false @@ -216,10 +231,23 @@ fun IntentNavigation( } transferring -> { CompactAppBar("Transfer workspace", onBack = back) - WorkspaceTransferScreen(session, selected, destinations, onBusy = { - operationBusy = - it - }, onDone = { transferring = false }) + WorkspaceTransferScreen( + session, + selected, + destinations, + onBusy = { + operationBusy = + it + }, + onDone = { transferring = false }, + workspaceTitle = selectedRow?.title ?: "Workspace", + sourceName = LocalConnectionName.current, + sourceState = if (LocalConnectionReady.current) { + dev.intent.core.protocol.session.SessionState.READY + } else { + dev.intent.core.protocol.session.SessionState.DISCONNECTED + } + ) } else -> workspaceContent(selected) { selectedWorkspace = null } } diff --git a/app/src/main/java/dev/intent/app/navigation/WorkspaceActionLauncher.kt b/app/src/main/java/dev/intent/app/navigation/WorkspaceActionLauncher.kt index 3ac527c..cbcfb54 100644 --- a/app/src/main/java/dev/intent/app/navigation/WorkspaceActionLauncher.kt +++ b/app/src/main/java/dev/intent/app/navigation/WorkspaceActionLauncher.kt @@ -1,70 +1,135 @@ package dev.intent.app.navigation import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.updateTransition +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize 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.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccountBox import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.Card -import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect 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.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.DialogWindowProvider @Composable internal fun WorkspaceActionLauncher(actions: LauncherActions, modifier: Modifier = Modifier) { var open by rememberSaveable { mutableStateOf(false) } + var anchor by remember { mutableStateOf(null) } + val transition = updateTransition(open, label = "Intent launcher") + val progress by transition.animateFloat( + transitionSpec = { + spring(dampingRatio = LAUNCHER_DAMPING, stiffness = LAUNCHER_STIFFNESS) + }, + label = "Intent button morph" + ) { if (it) 1f else 0f } + val modal = transition.currentState || transition.targetState BackHandler(open) { open = false } - FloatingActionButton(onClick = { open = true }, modifier = modifier) { - Icon(Icons.Default.Menu, "Open app launcher") + Box( + modifier.size(80.dp).onGloballyPositioned { anchor = it.boundsInWindow() } + .graphicsLayer { alpha = if (modal) 0f else 1f } + .then(if (modal) Modifier.clearAndSetSemantics {} else Modifier) + ) { + IntentLauncherButton(0f, false) { open = true } } - if (open) { + val bounds = anchor + if (modal && bounds != null) { Dialog( onDismissRequest = { open = false }, - properties = DialogProperties(usePlatformDefaultWidth = false) + properties = DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false + ) ) { - Box(Modifier.fillMaxSize()) { - Box( - Modifier.matchParentSize().clickable { open = false } - .semantics { contentDescription = "Dismiss app launcher" } - ) - Box(Modifier.align(Alignment.BottomCenter)) { - LauncherTiles(actions, { open = false }) + LauncherOverlay(actions, bounds, progress, open) { open = it } + } + } +} + +@Composable +private fun LauncherOverlay( + actions: LauncherActions, + anchor: Rect, + progress: Float, + open: Boolean, + onOpen: (Boolean) -> Unit +) { + val close = { onOpen(false) } + val window = (LocalView.current.parent as DialogWindowProvider).window + SideEffect { + window.setDimAmount(0f) + window.setWindowAnimations(0) + } + val density = LocalDensity.current + val fraction = progress.coerceIn(0f, 1f) + BoxWithConstraints(Modifier.fillMaxSize()) { + val bottom = (maxHeight - with(density) { anchor.bottom.toDp() }).coerceAtLeast(0.dp) + val end = (maxWidth - with(density) { anchor.right.toDp() }).coerceAtLeast(0.dp) + Box( + Modifier.matchParentSize().graphicsLayer { alpha = LAUNCHER_SHADE * fraction } + .background(Color.Black).clickable(onClick = close) + .semantics { contentDescription = "Dismiss app launcher" } + ) + Column( + Modifier.align(Alignment.BottomEnd).padding(bottom = bottom + 8.dp * fraction), + verticalArrangement = Arrangement.spacedBy(23.dp), + horizontalAlignment = Alignment.End + ) { + Box( + Modifier.graphicsLayer { + alpha = fraction + translationY = with(density) { 20.dp.toPx() } * (1 - fraction) } + ) { LauncherTiles(actions, open, close) } + Box(Modifier.padding(end = end)) { + IntentLauncherButton(progress, open) { onOpen(!open) } } } } } @Composable -private fun LauncherTiles(actions: LauncherActions, close: () -> Unit) { +private fun LauncherTiles(actions: LauncherActions, enabled: Boolean, close: () -> Unit) { val tiles = buildList { actions.onAssistant?.let { add(ChromeAction("Assistant", run = it)) } actions.onCreate?.let { add(ChromeAction("New workspace", run = it)) } @@ -72,53 +137,47 @@ private fun LauncherTiles(actions: LauncherActions, close: () -> Unit) { add(ChromeAction("Appearance: ${actions.appearance.name}", run = actions.onAppearance)) add(ChromeAction("Settings", run = actions.onSettings)) } - Column( - Modifier.padding(bottom = 16.dp), - verticalArrangement = Arrangement.spacedBy(24.dp), - horizontalAlignment = Alignment.End + LazyRow( + modifier = Modifier.testTag("launcher.tiles"), + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 24.dp) ) { - LazyRow( - modifier = Modifier.testTag("launcher.tiles"), - horizontalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 24.dp) - ) { - items(tiles, key = { it.label.substringBefore(':') }) { tile -> - Card( - onClick = { - if (!tile.label.startsWith("Appearance:")) close() - tile.run() - }, - modifier = Modifier.width(192.dp).height(240.dp) - .semantics { contentDescription = tile.label } - ) { - Column(Modifier.fillMaxSize().padding(24.dp)) { - val icon = when (tile.label.substringBefore(':')) { - "Connections" -> Icons.Default.AccountBox - "Appearance" -> Icons.Default.Refresh - "Settings" -> Icons.Default.Settings - else -> Icons.Default.Add - } - if (tile.label == "Assistant") { - Icon( - androidx.compose.ui.res.painterResource( - dev.intent.app.R.drawable.avatar_chief_of_staff - ), - null, - tint = androidx.compose.ui.graphics.Color.Unspecified - ) - } else { - Icon(icon, null, tint = MaterialTheme.colorScheme.onSurfaceVariant) - } - Spacer(Modifier.weight(1f)) - Text(tile.label, style = MaterialTheme.typography.titleMedium) + items(tiles, key = { it.label.substringBefore(':') }) { tile -> + Card( + enabled = enabled, + onClick = { + if (!tile.label.startsWith("Appearance:")) close() + tile.run() + }, + modifier = Modifier.width(192.dp).height(240.dp) + .semantics { contentDescription = tile.label } + ) { + Column(Modifier.fillMaxSize().padding(24.dp)) { + val icon = when (tile.label.substringBefore(':')) { + "Connections" -> Icons.Default.AccountBox + "Appearance" -> Icons.Default.Refresh + "Settings" -> Icons.Default.Settings + else -> Icons.Default.Add + } + if (tile.label == "Assistant") { + Icon( + androidx.compose.ui.res.painterResource( + dev.intent.app.R.drawable.avatar_chief_of_staff + ), + null, + tint = androidx.compose.ui.graphics.Color.Unspecified + ) + } else { + Icon(icon, null, tint = MaterialTheme.colorScheme.onSurfaceVariant) } + Spacer(Modifier.weight(1f)) + Text(tile.label, style = MaterialTheme.typography.titleMedium) } } } - Box(Modifier.padding(end = 16.dp)) { - FloatingActionButton(onClick = close) { - Icon(Icons.Default.Close, "Close app launcher") - } - } } } + +private const val LAUNCHER_DAMPING = 0.85f +private const val LAUNCHER_STIFFNESS = 322f +private const val LAUNCHER_SHADE = 0.32f diff --git a/app/src/main/java/dev/intent/app/navigation/WorkspaceAgentNavigation.kt b/app/src/main/java/dev/intent/app/navigation/WorkspaceAgentNavigation.kt new file mode 100644 index 0000000..2a3bab3 --- /dev/null +++ b/app/src/main/java/dev/intent/app/navigation/WorkspaceAgentNavigation.kt @@ -0,0 +1,9 @@ +package dev.intent.app.navigation + +import androidx.compose.runtime.staticCompositionLocalOf + +val LocalOpenWorkspaceAgent = staticCompositionLocalOf<((String, String) -> Unit)?> { null } + +val LocalConnectionReady = staticCompositionLocalOf { true } + +val LocalConnectionName = staticCompositionLocalOf { "Connected host" } diff --git a/app/src/main/java/dev/intent/app/navigation/WorkspaceContent.kt b/app/src/main/java/dev/intent/app/navigation/WorkspaceContent.kt index 24c055c..dcec9b2 100644 --- a/app/src/main/java/dev/intent/app/navigation/WorkspaceContent.kt +++ b/app/src/main/java/dev/intent/app/navigation/WorkspaceContent.kt @@ -14,8 +14,8 @@ import androidx.compose.runtime.saveable.rememberSaveableStateHolder import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import dev.intent.app.browser.WorkspaceBrowser import dev.intent.app.browser.WorkspaceBrowserLinks +import dev.intent.app.browser.WorkspaceBrowsersRoute import dev.intent.app.cache.OfflineCache import dev.intent.app.conversation.WorkspaceConversations import dev.intent.app.git.WorkspaceGitRoute @@ -23,6 +23,7 @@ import dev.intent.app.markdown.LocalMarkdownNoteLink import dev.intent.app.notes.NoteListRoute import dev.intent.app.notes.NoteReaderRoute import dev.intent.app.notes.SessionNoteSource +import dev.intent.app.scripts.WorkspaceScriptsRoute import dev.intent.app.terminals.WorkspaceTerminals import dev.intent.app.workspaces.LocalOpenRecentWorkspaceAgent import dev.intent.app.workspaces.WorkspaceDetailScreen @@ -38,25 +39,26 @@ fun WorkspaceContent( sectionScope: String = "" ) { val context = LocalContext.current.applicationContext + val connectionId = savedBrowserConnectionId(sectionScope) + val openAgent = LocalOpenWorkspaceAgent.current val preferences = remember(context, sectionScope) { WorkspaceSectionPreferences(context, sectionScope) } val openRecent = LocalOpenRecentWorkspaceAgent.current || dev.intent.app.history.LocalHistoryAgent.current != null + val initialSection = remember(session, workspaceId, openRecent) { + if (openRecent) WorkspaceDestination.Conversations else preferences.load(workspaceId) + } var destination by rememberSaveable( session, workspaceId, stateSaver = workspaceDestinationSaver ) { - mutableStateOf( - if (openRecent) WorkspaceDestination.Conversations else preferences.load(workspaceId) - ) + mutableStateOf(initialSection) } var section by rememberSaveable(session, workspaceId, stateSaver = workspaceDestinationSaver) { - mutableStateOf( - if (openRecent) WorkspaceDestination.Conversations else preferences.load(workspaceId) - ) + mutableStateOf(initialSection) } val historyAgent = dev.intent.app.history.LocalHistoryAgent.current LaunchedEffect(historyAgent, dev.intent.app.history.LocalHistorySelection.current) { @@ -94,6 +96,7 @@ fun WorkspaceContent( listOf( ChromeAction("Browser preview") { destination = WorkspaceDestination.Browser }, ChromeAction("Terminals") { destination = WorkspaceDestination.Terminals }, + ChromeAction("Scripts") { destination = WorkspaceDestination.Scripts }, ChromeAction("New terminal", run = newTerminal) ) + LocalChromeActions.current ), @@ -102,9 +105,21 @@ fun WorkspaceContent( } ) { WorkspaceBrowserLinks(session, workspaceId) { + val openBrowser = dev.intent.app.browser.LocalWorkspaceBrowserLink.current savedState.SaveableStateProvider(destination.routeKey()) { when (val current = destination) { - WorkspaceDestination.Browser -> WorkspaceBrowser(session, workspaceId, back) + WorkspaceDestination.Browser -> WorkspaceBrowsersRoute( + session, + connectionId, + workspaceId, + back + ) + WorkspaceDestination.Scripts -> WorkspaceScriptsRoute( + session, + workspaceId, + back, + onOpenBrowser = { openBrowser?.invoke(it) } + ) WorkspaceDestination.Git -> WorkspaceGitRoute(session, workspaceId, back) WorkspaceDestination.Terminals -> WorkspaceTerminals( session, @@ -132,7 +147,9 @@ fun WorkspaceContent( workspaceId, current.id, Modifier.weight(1f), - cache + cache, + onNoteSelected = { destination = WorkspaceDestination.Note(it) }, + onAgentSelected = noteAgentSelection(workspaceId, openAgent) ) else -> Unit } @@ -142,3 +159,14 @@ fun WorkspaceContent( } } } + +private fun noteAgentSelection( + workspaceId: String, + open: ((String, String) -> Unit)? +): ((String) -> Unit)? = open?.let { navigate -> + { agentId -> navigate(workspaceId, agentId) } +} + +@Composable +private fun savedBrowserConnectionId(fallback: String): String = + dev.intent.app.history.LocalHistoryNavigation.current?.connectionId ?: fallback diff --git a/app/src/main/java/dev/intent/app/navigation/WorkspaceDestination.kt b/app/src/main/java/dev/intent/app/navigation/WorkspaceDestination.kt index a716999..5316b29 100644 --- a/app/src/main/java/dev/intent/app/navigation/WorkspaceDestination.kt +++ b/app/src/main/java/dev/intent/app/navigation/WorkspaceDestination.kt @@ -7,6 +7,7 @@ sealed interface WorkspaceDestination { data object Conversations : WorkspaceDestination data object Git : WorkspaceDestination data object Terminals : WorkspaceDestination + data object Scripts : WorkspaceDestination data object Notes : WorkspaceDestination data class Note(val id: String) : WorkspaceDestination } @@ -34,6 +35,7 @@ internal fun WorkspaceDestination.routeKey(): String = when (this) { WorkspaceDestination.Conversations -> "conversations" WorkspaceDestination.Git -> "git" WorkspaceDestination.Terminals -> "terminals" + WorkspaceDestination.Scripts -> "scripts" WorkspaceDestination.Notes -> "notes" is WorkspaceDestination.Note -> "note:$id" } @@ -43,6 +45,7 @@ internal fun workspaceDestination(key: String): WorkspaceDestination = when (key "conversations" -> WorkspaceDestination.Conversations "git" -> WorkspaceDestination.Git "terminals" -> WorkspaceDestination.Terminals + "scripts" -> WorkspaceDestination.Scripts "notes" -> WorkspaceDestination.Notes else -> if (key.startsWith("note:")) { WorkspaceDestination.Note(key.removePrefix("note:")) diff --git a/app/src/main/java/dev/intent/app/navigation/WorkspaceSectionChrome.kt b/app/src/main/java/dev/intent/app/navigation/WorkspaceSectionChrome.kt index 8b67dd3..dcfb074 100644 --- a/app/src/main/java/dev/intent/app/navigation/WorkspaceSectionChrome.kt +++ b/app/src/main/java/dev/intent/app/navigation/WorkspaceSectionChrome.kt @@ -25,6 +25,7 @@ internal fun WorkspaceResources( FlowRow { TextButton(onClick = { open(WorkspaceDestination.Browser) }) { Text("Browser preview") } TextButton(onClick = { open(WorkspaceDestination.Terminals) }) { Text("Terminals") } + TextButton(onClick = { open(WorkspaceDestination.Scripts) }) { Text("Scripts") } TextButton(onClick = onCreateTerminal) { Text("New terminal") } } } diff --git a/app/src/main/java/dev/intent/app/notes/ContextNoteRow.kt b/app/src/main/java/dev/intent/app/notes/ContextNoteRow.kt index be153c9..0bb93c4 100644 --- a/app/src/main/java/dev/intent/app/notes/ContextNoteRow.kt +++ b/app/src/main/java/dev/intent/app/notes/ContextNoteRow.kt @@ -30,7 +30,9 @@ internal fun contextNoteRows( internal fun contextStatusLabel(status: String): String = when (status) { "not_started" -> "Not started" - "in_progress" -> "In progress" + "in_progress", "in-progress" -> "In progress" + "todo" -> "To do" + "done" -> "Done" "review_required" -> "Needs review" "discussion_needed" -> "Needs discussion" "complete" -> "Complete" diff --git a/app/src/main/java/dev/intent/app/notes/NoteReaderRoute.kt b/app/src/main/java/dev/intent/app/notes/NoteReaderRoute.kt index aee5c4d..b7e85b5 100644 --- a/app/src/main/java/dev/intent/app/notes/NoteReaderRoute.kt +++ b/app/src/main/java/dev/intent/app/notes/NoteReaderRoute.kt @@ -18,13 +18,16 @@ import dev.intent.app.comments.CommentStore import dev.intent.app.navigation.ChromeAction /** Route entry point; the caller owns the shared session, this route owns its note subscription. */ +@Suppress("LongParameterList") // Session scope and optional navigation callbacks. @Composable fun NoteReaderRoute( source: NoteSource, workspaceId: String, noteId: String, modifier: Modifier = Modifier, - cache: OfflineCache? = null + cache: OfflineCache? = null, + onNoteSelected: ((String) -> Unit)? = null, + onAgentSelected: ((String) -> Unit)? = null ) { val store = remember(source, workspaceId, noteId, cache) { NoteStore(source, cache) } var state by remember(store) { mutableStateOf(store.state) } @@ -64,7 +67,10 @@ fun NoteReaderRoute( modifier = Modifier.weight(1f), onTaskStatus = store::updateLine, onNoteStatus = store::updateStatus, - actions = actions + actions = actions, + onNoteSelected = onNoteSelected, + onAgentSelected = onAgentSelected, + onLinkedTaskStatus = store::updateLinkedStatus ) } } diff --git a/app/src/main/java/dev/intent/app/notes/NoteReaderScreen.kt b/app/src/main/java/dev/intent/app/notes/NoteReaderScreen.kt index 2c9a0e8..5db6efd 100644 --- a/app/src/main/java/dev/intent/app/notes/NoteReaderScreen.kt +++ b/app/src/main/java/dev/intent/app/notes/NoteReaderScreen.kt @@ -5,30 +5,36 @@ 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.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import dev.intent.app.design.IntentButton as Button import dev.intent.app.design.IntentScreen +import dev.intent.app.design.IntentTextButton as TextButton +import dev.intent.app.markdown.LocalMarkdownListItemRenderer +import dev.intent.app.markdown.LocalMarkdownNoteLink import dev.intent.app.markdown.MarkdownNode import dev.intent.app.markdown.children -import dev.intent.app.markdown.markdownFixture -import dev.intent.app.markdown.parseMarkdown +import dev.intent.app.markdown.markdownRuns import dev.intent.app.navigation.ChromeAction import dev.intent.app.navigation.CompactAppBar import dev.intent.core.protocol.models.Note import dev.intent.core.protocol.models.NoteTaskRow +import org.commonmark.node.ListItem // Independent note, task, and error states with explicit page actions. -@Suppress("CyclomaticComplexMethod", "LongParameterList") +@Suppress("CyclomaticComplexMethod", "LongParameterList", "LongMethod") @Composable fun NoteReaderScreen( state: NoteReaderState, @@ -36,10 +42,17 @@ fun NoteReaderScreen( modifier: Modifier = Modifier, onTaskStatus: (NoteTaskRow, String) -> Unit = { _, _ -> }, onNoteStatus: (String) -> Unit = {}, - actions: List = emptyList() + actions: List = emptyList(), + onNoteSelected: ((String) -> Unit)? = null, + onAgentSelected: ((String) -> Unit)? = null, + onLinkedTaskStatus: (String, String) -> Unit = { _, _ -> } ) { val body = state.note?.content - val document = remember(body) { parseMarkdown(body.orEmpty()) } + val document = remember(body, state.tasks) { parseNoteMarkdown(body.orEmpty(), state.tasks) } + val lines = remember(body) { body.orEmpty().lines() } + val onNote = onNoteSelected ?: LocalMarkdownNoteLink.current + val enabled = !state.saving && !state.loading && state.error == null + val notes = state.relatedNotes.filterNot { it.id == state.note?.id } + listOfNotNull(state.note) Column(modifier.fillMaxSize()) { CompactAppBar( state.note?.title ?: "Note", @@ -53,16 +66,24 @@ fun NoteReaderScreen( state.mutationError?.let { message -> item { Text(message, color = MaterialTheme.colorScheme.error) } } - state.note?.metadata?.task?.let { task -> - item { - TaskStatusMenu( - task.status, - taskNoteStatuses, - !state.saving && !state.loading, - onNoteStatus - ) + state.note?.let { note -> + if (note.tags.isNotEmpty()) { + item { Text(note.tags.joinToString(" · ") { "#$it" }) } + } + if (note.metadata?.task != null) { + item { + NoteTaskMetadata( + note, + notes, + enabled, + onNoteStatus, + onNote, + onAgentSelected + ) + } } } + state.relatedNotesError?.let { message -> item { Text(message) } } state.error?.let { message -> item { Column { @@ -75,35 +96,94 @@ fun NoteReaderScreen( if (state.note != null && body == null) { item { Text("The note body is not available.") } } - if (state.note?.contentTruncated == - true - ) { - item { Text("This note shows only part of its content.") } + state.note?.takeIf { it.contentTruncated }?.let { note -> + item { + Column { + Text(noteTruncationMessage(note)) + TextButton(onClick = onRetry) { Text("Retry loading note") } + } + } } if (body == "") item { Text("This note is empty.") } - items(document.children()) { MarkdownNode(it) } - if (state.tasks.isNotEmpty()) { - item { Text("Tasks", style = MaterialTheme.typography.titleLarge) } - items(state.tasks) { task -> - NoteTaskRowView(task, !state.saving && !state.loading) { - onTaskStatus(task, it) + items(document.children()) { node -> + CompositionLocalProvider( + LocalMarkdownNoteLink provides onNote, + LocalMarkdownListItemRenderer provides { item -> + val task = noteTaskForItem(item, lines, state.tasks) + if (task == null) { + false + } else { + InlineNoteTask( + item, + task, + notes, + enabled, + onNote, + { onTaskStatus(task, it) }, + onLinkedTaskStatus + ) + true + } } + ) { + MarkdownNode(node) } } } } } +@Suppress("LongParameterList") // Plain rows and linked cards use different protocol mutations. @Composable -fun NoteTaskRowView(task: NoteTaskRow, enabled: Boolean = true, onStatus: (String) -> Unit = {}) { - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Checkbox( - checked = task.status == "done", - onCheckedChange = { onStatus(if (it) "done" else "todo") }, - enabled = enabled - ) - Text(task.text, Modifier.weight(1f)) - TaskStatusMenu(task.status, checkboxStatuses, enabled, onStatus) +private fun InlineNoteTask( + item: ListItem, + task: NoteTaskRow, + notes: List, + enabled: Boolean, + onNote: ((String) -> Unit)?, + onStatus: (String) -> Unit, + onLinkedStatus: (String, String) -> Unit +) { + val linkedId = linkedTaskId(task) + if (linkedId == null) { + Column { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Checkbox( + checked = task.status == "done", + onCheckedChange = { onStatus(if (it) "done" else "todo") }, + enabled = enabled + ) + Column(Modifier.weight(1f)) { + noteTaskBody(item).forEach { MarkdownNode(it) } + TaskStatusMenu(task.status, checkboxStatuses, enabled, onStatus) + } + } + } + } else { + val linked = notes.firstOrNull { it.id == linkedId && !it.isArchived } + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.small, + modifier = Modifier.fillMaxWidth() + ) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + val title = linked?.title ?: noteTaskBody(item).firstOrNull()?.let { + markdownRuns(it).joinToString("") { run -> run.text } + } ?: task.text + if (onNote != null) { + TextButton(onClick = { onNote(linkedId) }) { Text(title) } + } else { + Text(title, style = MaterialTheme.typography.titleSmall) + } + TaskStatusMenu( + linked?.metadata?.task?.status ?: "Details unavailable", + taskNoteStatuses, + enabled && linked?.metadata?.task != null, + { onLinkedStatus(linkedId, it) } + ) + linkedNoteTaskBody(item, linkedId).forEach { MarkdownNode(it) } + } + } } } @@ -117,11 +197,11 @@ private fun NoteReaderPreview() { note = Note( id = "spec", title = "Project plan", - content = markdownFixture + content = "Project plan\n\n- [x] Load the note\n- [/] Check the result" ), tasks = listOf( - NoteTaskRow(2, "Load the note", "done"), - NoteTaskRow(3, "Check the result", "in-progress") + NoteTaskRow(3, "Load the note", "done"), + NoteTaskRow(4, "Check the result", "in-progress") ), loading = false ), diff --git a/app/src/main/java/dev/intent/app/notes/NoteStore.kt b/app/src/main/java/dev/intent/app/notes/NoteStore.kt index 5ae1541..a5976b4 100644 --- a/app/src/main/java/dev/intent/app/notes/NoteStore.kt +++ b/app/src/main/java/dev/intent/app/notes/NoteStore.kt @@ -15,6 +15,8 @@ import java.util.concurrent.CompletionException interface NoteSource { fun note(workspaceId: String, noteId: String): CompletableFuture fun tasks(workspaceId: String, noteId: String): CompletableFuture> + fun relatedNotes(workspaceId: String): CompletableFuture> = + CompletableFuture.completedFuture(emptyList()) fun updateLine( workspaceId: String, noteId: String, @@ -47,7 +49,9 @@ data class NoteReaderState( val deleted: Boolean = false, val saving: Boolean = false, val mutationError: String? = null, - val error: String? = null + val error: String? = null, + val relatedNotes: List = emptyList(), + val relatedNotesError: String? = null ) /** A store owns one visible note. All callbacks and state changes are serialized by its lock. */ @@ -113,6 +117,19 @@ class NoteStore(private val source: NoteSource, private val cache: OfflineCache? tasks -> NoteReaderState(note = note, tasks = tasks, loading = false) + }.thenCombine( + source.relatedNotes(workspaceId).handle { notes, error -> + notes to error + } + ) { result, related -> + result.copy( + relatedNotes = related.first.orEmpty(), + relatedNotesError = if (related.second != null) { + "Related task details are unavailable. Refresh to retry." + } else { + null + } + ) }.whenComplete { result, error -> synchronized(this) { if (current == generation && revision == request) { @@ -157,6 +174,25 @@ class NoteStore(private val source: NoteSource, private val cache: OfflineCache? } } + @Suppress("ReturnCount") // Only resolved linked tasks can receive task-note status writes. + @Synchronized + fun updateLinkedStatus(taskId: String, status: String) { + if (status !in taskNoteStatuses || state.saving || state.loading) return + if (state.tasks.none { linkedTaskId(it) == taskId }) return + if (taskId == noteId) { + updateStatus(status) + return + } + val linked = state.relatedNotes.firstOrNull { it.id == taskId } ?: return + if (linked.metadata?.task == null || linked.isArchived) return + mutate(state) { + source.updateStatus(workspaceId, taskId, status, linked.rev).thenApply { + check(it.ok) + null + } + } + } + @Suppress("ReturnCount") // Task metadata must exist before a status write. @Synchronized fun updateStatus(status: String) { @@ -209,7 +245,7 @@ class NoteStore(private val source: NoteSource, private val cache: OfflineCache? private fun receive(event: NoteEvent) { if (event.type == "note:deleted" && event.noteId == noteId) { deleted() - } else if (event.type.startsWith("task:") || event.noteId == noteId) { + } else if (event.type.startsWith("task:") || event.type.startsWith("note:")) { refresh() } } diff --git a/app/src/main/java/dev/intent/app/notes/NoteTaskMetadata.kt b/app/src/main/java/dev/intent/app/notes/NoteTaskMetadata.kt new file mode 100644 index 0000000..b9d5998 --- /dev/null +++ b/app/src/main/java/dev/intent/app/notes/NoteTaskMetadata.kt @@ -0,0 +1,78 @@ +package dev.intent.app.notes + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import dev.intent.app.design.IntentTextButton as TextButton +import dev.intent.core.protocol.models.Note + +@Suppress("LongParameterList") // Status writes and note/agent navigation are independent actions. +@Composable +internal fun NoteTaskMetadata( + note: Note, + notes: List, + enabled: Boolean, + onStatus: (String) -> Unit, + onNote: ((String) -> Unit)?, + onAgent: ((String) -> Unit)? +) { + val task = note.metadata?.task ?: return + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + TaskStatusMenu(task.status, taskNoteStatuses, enabled, onStatus) + if (task.assignedAgentIds.isNotEmpty()) { + Text("Assigned agents", style = MaterialTheme.typography.labelLarge) + task.assignedAgentIds.distinct().forEach { id -> + if (onAgent == null) { + Text(id, style = MaterialTheme.typography.bodyMedium) + } else { + TextButton(onClick = { onAgent(id) }) { Text(id) } + } + } + } + NoteRelations( + "Depends on", + task.dependsOn, + notes, + if (task.status == "complete") emptyList() else task.unmetDependsOn, + onNote + ) + NoteRelations("Depended on by", dependedOnBy(note.id, notes), notes, emptyList(), onNote) + NoteRelations("Conflicts with", task.conflictsWith, notes, emptyList(), onNote) + task.blockedReason?.takeIf { it.isNotBlank() }?.let { + Text("Blocked: $it", style = MaterialTheme.typography.bodyMedium) + } + } +} + +@Composable +private fun NoteRelations( + label: String, + ids: List, + notes: List, + unmet: List, + onNote: ((String) -> Unit)? +) { + if (ids.isEmpty()) return + Text(label, style = MaterialTheme.typography.labelLarge) + ids.distinct().forEach { id -> + val related = notes.firstOrNull { it.id == id && !it.isArchived } + val status = related?.metadata?.task?.status?.let(::contextStatusLabel) + val text = listOfNotNull( + related?.title ?: id, + status, + "Unmet dependency".takeIf { id in unmet } + ).joinToString(" · ") + if (onNote != null && validNoteReference(id)) { + TextButton(onClick = { onNote(id) }, modifier = Modifier.fillMaxWidth()) { + Text(text, Modifier.fillMaxWidth()) + } + } else { + Text(text, style = MaterialTheme.typography.bodyMedium) + } + } +} diff --git a/app/src/main/java/dev/intent/app/notes/NoteTaskPresentation.kt b/app/src/main/java/dev/intent/app/notes/NoteTaskPresentation.kt new file mode 100644 index 0000000..8063255 --- /dev/null +++ b/app/src/main/java/dev/intent/app/notes/NoteTaskPresentation.kt @@ -0,0 +1,94 @@ +package dev.intent.app.notes + +import dev.intent.app.markdown.children +import dev.intent.app.markdown.markdownNoteId +import dev.intent.app.markdown.markdownRuns +import dev.intent.app.markdown.parseMarkdown +import dev.intent.core.protocol.models.Note +import dev.intent.core.protocol.models.NoteTaskRow +import org.commonmark.ext.task.list.items.TaskListItemMarker +import org.commonmark.node.Link +import org.commonmark.node.ListItem +import org.commonmark.node.Node +import org.commonmark.node.Paragraph +import org.commonmark.node.Text + +private val checkboxLine = Regex("""^[ \t]*- \[[ xX/]\]\s*(.*)$""") + +/** Preserve the full Markdown tree and reference-link context, including nested lists. */ +internal fun parseNoteMarkdown(content: String, tasks: List = emptyList()): Node { + val document = parseMarkdown(content, sourcePositions = true) + val lines = content.lines() + fun visit(node: Node) { + if (node is ListItem) { + val line = node.sourceSpans.firstOrNull()?.lineIndex?.let(lines::getOrNull) + if (line != null && + noteTaskForItem(node, lines, tasks) != null && + line.trimStart().startsWith("- [/]") + ) { + val text = node.children().filterIsInstance().firstOrNull()?.firstChild + if (text is Text && text.literal.startsWith("[/]")) { + text.literal = text.literal.removePrefix("[/]").trimStart() + } + } + } + node.children().forEach(::visit) + } + visit(document) + return document +} + +/** Never bind a server row to a different source line or a checkbox inside a code fence. */ +@Suppress("ReturnCount") // Reject ambiguous source/host mappings before enabling writes. +internal fun noteTaskForItem( + item: ListItem, + lines: List, + tasks: List +): NoteTaskRow? { + val line = item.sourceSpans.firstOrNull()?.lineIndex ?: return null + val source = lines.getOrNull(line) ?: return null + if (!checkboxLine.matches(source)) return null + val task = tasks.singleOrNull { it.lineNumber == line + 1 } ?: return null + val linked = linkedTaskId(task) + if ((task.taskNoteId != null || task.linkedTaskNoteId != null) && linked == null) return null + if (linked != null) { + val paragraph = item.children().filterIsInstance().firstOrNull() ?: return null + if (markdownRuns(paragraph).none { it.url?.let(::markdownNoteId) == linked }) return null + } + return task +} + +internal fun noteTaskBody(item: ListItem): List = + item.children().filterNot { it is TaskListItemMarker } + +/** The card title replaces only a title-only first paragraph, never prose or nested blocks. */ +internal fun linkedNoteTaskBody(item: ListItem, linkedId: String): List = + noteTaskBody(item).filterIndexed { index, node -> + val content = (node as? Paragraph)?.children()?.filterNot { + it is TaskListItemMarker || (it is Text && it.literal.isBlank()) + } + val link = content?.singleOrNull() as? Link + index != 0 || link == null || markdownNoteId(link.destination) != linkedId + } + +internal fun linkedTaskId(task: NoteTaskRow): String? = + (task.taskNoteId ?: task.linkedTaskNoteId)?.takeIf(::validNoteReference) + +internal fun validNoteReference(id: String): Boolean = id.isNotBlank() && + id != "." && + id != ".." && + id.none { it == '/' || it == '\\' || it == '?' || it == '#' || it.isISOControl() } + +/** Relations are one-hop links, never recursively rendered, so cycles are harmless. */ +internal fun dependedOnBy(noteId: String, notes: List): List = notes + .filter { + !it.isArchived && it.id != noteId && noteId in it.metadata?.task?.dependsOn.orEmpty() + } + .map { it.id }.distinct().sorted() + +internal fun noteTruncationMessage(note: Note): String { + val shown = note.content.orEmpty().toByteArray(Charsets.UTF_8).size + val total = note.contentBytes?.takeIf { it > shown } + return "This note is partly loaded. Showing $shown bytes" + + (total?.let { " of $it bytes." } ?: ".") +} diff --git a/app/src/main/java/dev/intent/app/notes/SessionNoteSource.kt b/app/src/main/java/dev/intent/app/notes/SessionNoteSource.kt index c538b60..6930e07 100644 --- a/app/src/main/java/dev/intent/app/notes/SessionNoteSource.kt +++ b/app/src/main/java/dev/intent/app/notes/SessionNoteSource.kt @@ -1,6 +1,7 @@ package dev.intent.app.notes import dev.intent.app.comments.RpcCommentSession +import dev.intent.core.protocol.models.NoteListResult import dev.intent.core.protocol.models.NoteResult import dev.intent.core.protocol.models.NoteTaskRow import dev.intent.core.protocol.models.TaskUpdateNoteStatusParams @@ -37,6 +38,15 @@ class SessionNoteSource(private val session: RpcSession) : NoteSource { ListSerializer(NoteTaskRow.serializer()) ) + override fun relatedNotes(workspaceId: String) = session.request( + "note.list", + buildJsonObject { + put("workspaceId", workspaceId) + put("projection", "slim") + }, + NoteListResult.serializer() + ).thenApply { it.notes } + override fun updateLine( workspaceId: String, noteId: String, diff --git a/app/src/main/java/dev/intent/app/notes/TaskStatusControls.kt b/app/src/main/java/dev/intent/app/notes/TaskStatusControls.kt index 9a990aa..74ff00e 100644 --- a/app/src/main/java/dev/intent/app/notes/TaskStatusControls.kt +++ b/app/src/main/java/dev/intent/app/notes/TaskStatusControls.kt @@ -35,12 +35,12 @@ fun TaskStatusMenu( AssistChip( onClick = { expanded = true }, enabled = enabled, - label = { Text(status.replace('_', ' ').replace('-', ' ')) } + label = { Text(contextStatusLabel(status)) } ) DropdownMenu(expanded = expanded && enabled, onDismissRequest = { expanded = false }) { choices.forEach { choice -> DropdownMenuItem( - text = { Text(choice.replace('_', ' ').replace('-', ' ')) }, + text = { Text(contextStatusLabel(choice)) }, onClick = { expanded = false onStatus(choice) diff --git a/app/src/main/java/dev/intent/app/scripts/WorkspaceScriptSession.kt b/app/src/main/java/dev/intent/app/scripts/WorkspaceScriptSession.kt new file mode 100644 index 0000000..5c16565 --- /dev/null +++ b/app/src/main/java/dev/intent/app/scripts/WorkspaceScriptSession.kt @@ -0,0 +1,69 @@ +package dev.intent.app.scripts + +import dev.intent.core.protocol.models.WorkspaceScriptActionResult +import dev.intent.core.protocol.models.WorkspaceScriptList +import dev.intent.core.protocol.models.WorkspaceScriptResource +import dev.intent.core.protocol.models.WorkspaceScriptRuntime +import dev.intent.core.protocol.session.RpcSession +import dev.intent.core.protocol.session.SubscriptionSpec +import java.io.Closeable +import java.util.concurrent.CompletableFuture +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +interface WorkspaceScriptSession { + fun list(): CompletableFuture> + fun status(id: String): CompletableFuture + fun setRunning(id: String, running: Boolean): CompletableFuture + fun subscribe(changed: () -> Unit, ready: () -> Unit, failed: (Throwable) -> Unit): Closeable +} + +class RpcWorkspaceScriptSession(private val session: RpcSession, private val workspaceId: String) : + WorkspaceScriptSession { + override fun list(): CompletableFuture> = session.request( + "script.list", + params(), + WorkspaceScriptList.serializer() + ).thenApply { it.scripts } + + override fun status(id: String): CompletableFuture = session.request( + "script.status", + params(id), + WorkspaceScriptRuntime.serializer() + ) + + override fun setRunning(id: String, running: Boolean) = session.request( + if (running) "script.start" else "script.stop", + params(id), + WorkspaceScriptActionResult.serializer() + ) + + override fun subscribe(changed: () -> Unit, ready: () -> Unit, failed: (Throwable) -> Unit) = + session.subscribe( + SubscriptionSpec( + "events.subscribe", + buildJsonObject { + put("workspaceId", workspaceId) + put( + "eventTypes", + JsonArray(listOf("script:state", "script:changed").map(::JsonPrimitive)) + ) + }, + notificationMethod = "events.event" + ), + onMessage = { payload -> + val event = payload["event"] as? JsonObject + if (event?.get("workspaceId") == JsonPrimitive(workspaceId)) changed() + }, + onSubscribed = { ready() }, + onError = failed + ) + + private fun params(id: String? = null) = buildJsonObject { + put("workspaceId", workspaceId) + id?.let { put("scriptId", it) } + } +} diff --git a/app/src/main/java/dev/intent/app/scripts/WorkspaceScriptsRoute.kt b/app/src/main/java/dev/intent/app/scripts/WorkspaceScriptsRoute.kt new file mode 100644 index 0000000..379cbe0 --- /dev/null +++ b/app/src/main/java/dev/intent/app/scripts/WorkspaceScriptsRoute.kt @@ -0,0 +1,156 @@ +package dev.intent.app.scripts + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.remember +import androidx.compose.ui.Modifier +import dev.intent.app.design.IntentScreen +import dev.intent.app.design.IntentSection +import dev.intent.app.design.IntentTextButton +import dev.intent.app.navigation.LocalConnectionReady +import dev.intent.core.protocol.browser.PreviewUrl +import dev.intent.core.protocol.models.WorkspaceScriptResource +import dev.intent.core.protocol.models.WorkspaceScriptRuntime +import dev.intent.core.protocol.session.RpcSession + +@Composable +fun WorkspaceScriptsRoute( + session: RpcSession, + workspaceId: String, + onBack: () -> Unit, + onOpenBrowser: (String) -> Unit +) { + val connectionReady = LocalConnectionReady.current + val store = remember(session, workspaceId) { + WorkspaceScriptsStore(RpcWorkspaceScriptSession(session, workspaceId), workspaceId) + } + DisposableEffect(store) { + store.start(connectionReady) + onDispose { store.close() } + } + LaunchedEffect(store, connectionReady) { store.connectionChanged(connectionReady) } + val state by store.state.collectAsState() + WorkspaceScriptsScreen( + state.copy(connected = connectionReady && state.connected), + onBack, + store::refresh, + store::setRunning, + onOpenBrowser + ) +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun WorkspaceScriptsScreen( + state: WorkspaceScriptsState, + onBack: () -> Unit, + refresh: () -> Unit, + setRunning: (String, Boolean) -> Unit, + onOpenBrowser: (String) -> Unit +) { + LazyColumn( + Modifier.fillMaxSize(), + contentPadding = IntentScreen.pageInsets, + verticalArrangement = Arrangement.spacedBy(IntentScreen.itemSpacing) + ) { + item { + Column { + Text("Scripts", style = MaterialTheme.typography.headlineSmall) + Text("Under development", color = IntentScreen.secondaryText) + FlowRow { + IntentTextButton(onClick = onBack) { Text("Back") } + IntentTextButton( + onClick = refresh, + enabled = state.connected && !state.loading + ) { + Text("Refresh") + } + } + } + } + if (state.loading) item { LinearProgressIndicator(Modifier.fillMaxWidth()) } + state.error?.let { message -> item { Text(message, color = IntentScreen.error) } } + if (!state.loading && state.scripts.isEmpty() && state.error == null) { + item { + IntentSection("No scripts yet") { + Text("Create a workspace script on the host to run it here.") + } + } + } + items(state.scripts, key = { it.id }) { script -> + ScriptResourceRow( + script, + enabled = state.connected && !state.loading && script.id !in state.pending, + pending = script.id in state.pending, + setRunning = { setRunning(script.id, it) }, + onOpenBrowser = onOpenBrowser + ) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun ScriptResourceRow( + script: WorkspaceScriptResource, + enabled: Boolean, + pending: Boolean, + setRunning: (Boolean) -> Unit, + onOpenBrowser: (String) -> Unit +) { + val runtime = script.runtime + IntentSection(script.name.ifBlank { script.id }) { + Text(script.command, style = MaterialTheme.typography.bodySmall) + Text("${script.mode} · ${scriptStatusLabel(runtime)}", color = IntentScreen.secondaryText) + runtime?.error?.let { Text(it, color = IntentScreen.error) } + if (runtime?.previouslyRunning == true) Text("Stopped when the host restarted.") + FlowRow { + IntentTextButton( + onClick = { setRunning(runtime?.isLive != true) }, + enabled = enabled && runtime != null + ) { + Text( + if (pending) { + "Updating…" + } else if (runtime?.isLive == true) { + "Stop" + } else { + "Start" + } + ) + } + val url = runtime?.detectedUrl?.takeIf { + runtime.status == "running" && runCatching { PreviewUrl.parse(it) }.isSuccess + } + if (url != null) { + IntentTextButton(onClick = { + onOpenBrowser(url) + }, enabled = enabled) { Text("Open preview") } + } + } + } +} + +internal fun scriptStatusLabel(runtime: WorkspaceScriptRuntime?): String = when (runtime?.status) { + null -> "Status unavailable" + "idle" -> "Idle" + "starting" -> "Starting" + "running" -> "Running" + "restarting" -> "Restarting" + "exited" -> runtime.exitCode?.let { "Exited ($it)" } ?: "Exited" + else -> runtime.status +} diff --git a/app/src/main/java/dev/intent/app/scripts/WorkspaceScriptsStore.kt b/app/src/main/java/dev/intent/app/scripts/WorkspaceScriptsStore.kt new file mode 100644 index 0000000..7f8ca8c --- /dev/null +++ b/app/src/main/java/dev/intent/app/scripts/WorkspaceScriptsStore.kt @@ -0,0 +1,170 @@ +package dev.intent.app.scripts + +import dev.intent.core.protocol.models.WorkspaceScriptResource +import java.io.Closeable +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +data class WorkspaceScriptsState( + val scripts: List = emptyList(), + val loading: Boolean = true, + val connected: Boolean = false, + val pending: Set = emptySet(), + val error: String? = null +) + +/** A route owns one store; reconnect reloads reads but never replays start/stop. */ +class WorkspaceScriptsStore( + private val session: WorkspaceScriptSession, + private val workspaceId: String +) : Closeable { + private val mutableState = MutableStateFlow(WorkspaceScriptsState()) + val state = mutableState.asStateFlow() + private var subscription: Closeable? = null + private var closed = false + private var generation = 0L + private var revision = 0L + private var reading = false + private var dirty = false + private var connectionReady = true + private var admitted = false + + @Synchronized + fun start(ready: Boolean = true) { + if (closed || subscription != null) return + connectionReady = ready + if (!ready) invalidateConnection() + subscription = session.subscribe(::refresh, ::connected, ::disconnected) + } + + /** Ordinary transport loss does not invoke an admitted subscription's error callback. */ + @Synchronized + fun connectionChanged(ready: Boolean) { + if (closed || ready == connectionReady) return + connectionReady = ready + if (!ready) { + admitted = false + invalidateConnection() + } else if (admitted) { + activate() + } + } + + @Synchronized + private fun connected() { + if (closed) return + admitted = true + if (connectionReady) activate() + } + + private fun activate() { + generation++ + reading = false + mutableState.value = state.value.copy(connected = true, pending = emptySet()) + refresh() + } + + @Synchronized + fun refresh() { + if (closed || !state.value.connected) return + revision++ + if (reading) { + dirty = true + return + } + reading = true + dirty = false + val token = generation + val readRevision = revision + mutableState.value = state.value.copy(loading = true, error = null) + session.list().whenComplete { rows, error -> + synchronized(this) { + if (!closed && token == generation) { + reading = false + if (dirty || readRevision != revision) { + refresh() + } else if (error != null) { + mutableState.value = state.value.copy(loading = false, error = LOAD_ERROR) + } else { + mutableState.value = state.value.copy( + scripts = rows.filter { + it.workspaceId == workspaceId && + it.id.isNotBlank() + } + .distinctBy { it.id }, + loading = false, + error = null + ) + } + } + } + } + } + + @Synchronized + fun setRunning(id: String, running: Boolean) { + val current = state.value + if (!canSetRunning(current, id, running)) return + val token = generation + mutableState.value = current.copy(pending = current.pending + id, error = null) + // Confirm with a status read; do not claim success from an optimistic local state. + session.setRunning(id, running).thenCompose { result -> + check(result.ok && result.scriptId == id) + session.status(id) + }.whenComplete { _, error -> + synchronized(this) { + if (!closed && token == generation) { + mutableState.value = state.value.copy(pending = state.value.pending - id) + if (error != null) { + mutableState.value = state.value.copy( + error = "Script action was not confirmed. " + + "Refresh its status before trying again." + ) + } else { + refresh() + } + } + } + } + } + + private fun canSetRunning( + current: WorkspaceScriptsState, + id: String, + running: Boolean + ): Boolean { + val runtime = current.scripts.firstOrNull { it.id == id }?.runtime ?: return false + val available = !closed && current.connected && !current.loading + val actionable = id !in current.pending && running != runtime.isLive + return available && actionable + } + + @Synchronized + private fun disconnected(@Suppress("UNUSED_PARAMETER") error: Throwable) { + if (closed) return + admitted = false + invalidateConnection() + } + + private fun invalidateConnection() { + generation++ + reading = false + dirty = false + mutableState.value = state.value.copy( + loading = false, + connected = false, + pending = emptySet(), + error = "Script updates are unavailable. Reconnect to refresh their status." + ) + } + + @Synchronized + override fun close() { + closed = true + generation++ + subscription?.close() + subscription = null + } +} + +private const val LOAD_ERROR = "Cannot load scripts. Refresh to try again." diff --git a/app/src/main/java/dev/intent/app/status/AgentWatches.kt b/app/src/main/java/dev/intent/app/status/AgentWatches.kt index 85b2e18..edb0ec6 100644 --- a/app/src/main/java/dev/intent/app/status/AgentWatches.kt +++ b/app/src/main/java/dev/intent/app/status/AgentWatches.kt @@ -80,3 +80,12 @@ private fun JsonObject.ids(key: String): List = (get(key) as? JsonArray).orEmpty().mapNotNull { (it as? JsonPrimitive)?.contentOrNull?.takeIf(String::isNotBlank) } + +internal data class WatchedAgentTarget(val workspaceId: String, val agentId: String) + +internal fun WatchedAgent.navigationTarget(currentWorkspaceId: String?): WatchedAgentTarget? { + if (deleted || agent.id.isBlank()) return null + val workspaceId = agent.workspaceId?.takeIf(String::isNotBlank) + ?: currentWorkspaceId?.takeIf(String::isNotBlank) + return workspaceId?.let { WatchedAgentTarget(it, agent.id) } +} diff --git a/app/src/main/java/dev/intent/app/status/ConversationStatusStrip.kt b/app/src/main/java/dev/intent/app/status/ConversationStatusStrip.kt index 0a2572e..a78717f 100644 --- a/app/src/main/java/dev/intent/app/status/ConversationStatusStrip.kt +++ b/app/src/main/java/dev/intent/app/status/ConversationStatusStrip.kt @@ -49,7 +49,9 @@ fun ConversationStatusStrip( onAgent: (String) -> Unit = {}, agentWatches: AgentWatches = AgentWatches(), onRetryAgents: () -> Unit = {}, - onPrAction: (String, PrMonitorAction) -> Unit = { _, _ -> } + onPrAction: (String, PrMonitorAction) -> Unit = { _, _ -> }, + onWatchedAgent: ((workspaceId: String, agentId: String) -> Unit)? = + dev.intent.app.navigation.LocalOpenWorkspaceAgent.current ) { val now = watchClock() Column( @@ -68,8 +70,17 @@ fun ConversationStatusStrip( Column { agentWatches.rows.forEach { row -> AssistChip( - onClick = { onAgent(row.agent.id) }, - enabled = !row.deleted && row.agent.workspaceId == agent?.workspaceId, + onClick = { + val target = row.navigationTarget(agent?.workspaceId) + if (target != null && onWatchedAgent != null) { + onWatchedAgent(target.workspaceId, target.agentId) + } else { + onAgent(row.agent.id) + } + }, + enabled = row.navigationTarget(agent?.workspaceId)?.let { + onWatchedAgent != null || it.workspaceId == agent?.workspaceId + } == true, modifier = Modifier.testTag("watched-agent-${row.agent.id}"), label = { Text("${row.agent.name} · ${row.label}") } ) diff --git a/app/src/main/java/dev/intent/app/terminals/TerminalListActions.kt b/app/src/main/java/dev/intent/app/terminals/TerminalListActions.kt new file mode 100644 index 0000000..ebffaa7 --- /dev/null +++ b/app/src/main/java/dev/intent/app/terminals/TerminalListActions.kt @@ -0,0 +1,7 @@ +package dev.intent.app.terminals + +internal data class TerminalListActions( + val create: () -> Unit = {}, + val delete: (String) -> Unit = {}, + val rename: (String, String) -> Unit = { _, _ -> } +) diff --git a/app/src/main/java/dev/intent/app/terminals/TerminalListScreen.kt b/app/src/main/java/dev/intent/app/terminals/TerminalListScreen.kt index ff1b877..25209c8 100644 --- a/app/src/main/java/dev/intent/app/terminals/TerminalListScreen.kt +++ b/app/src/main/java/dev/intent/app/terminals/TerminalListScreen.kt @@ -8,9 +8,11 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Edit import androidx.compose.material3.AlertDialog import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -24,17 +26,37 @@ import dev.intent.app.design.IntentScreen import dev.intent.app.design.IntentSection import dev.intent.app.design.IntentTextButton as TextButton import dev.intent.app.lists.RefreshableList +import dev.intent.core.protocol.models.TerminalInfo + +private class TerminalDialogState { + var renaming by mutableStateOf(null) + var name by mutableStateOf("") + var deleting by mutableStateOf(null) +} + +private val TerminalState.mutationsEnabled: Boolean + get() = connected && !loading && !creating && deleting == null @Composable internal fun TerminalListScreen( state: TerminalState, onRefresh: () -> Unit, onSelect: (String) -> Unit, - onCreate: () -> Unit = {}, - onDelete: (String) -> Unit = {} + actions: TerminalListActions = TerminalListActions() +) { + val dialogs = remember { TerminalDialogState() } + TerminalListContent(state, onRefresh, onSelect, dialogs, actions) + TerminalDialogs(state, dialogs, actions) +} + +@Composable +private fun TerminalListContent( + state: TerminalState, + onRefresh: () -> Unit, + onSelect: (String) -> Unit, + dialogs: TerminalDialogState, + actions: TerminalListActions ) { - var deleting by remember { mutableStateOf(null) } - val enabled = state.connected && !state.loading && !state.creating && state.deleting == null RefreshableList(state.loading, onRefresh) { LazyColumn( Modifier.fillMaxSize(), @@ -42,7 +64,7 @@ internal fun TerminalListScreen( verticalArrangement = Arrangement.spacedBy(IntentScreen.itemSpacing) ) { item { - TextButton(onClick = onCreate, enabled = enabled) { + TextButton(onClick = actions.create, enabled = state.mutationsEnabled) { Text(if (state.creating) "Creating terminal…" else "New terminal") } } @@ -56,41 +78,114 @@ internal fun TerminalListScreen( } } items(state.terminals, key = { it.id }) { terminal -> - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - TextButton( - onClick = { onSelect(terminal.id) }, - enabled = !state.loading && state.deleting != terminal.id, - modifier = Modifier.weight(1f).testTag("terminal-open-${terminal.id}") - ) { - Text("${terminal.name}\n${terminal.cwd}") - } - IconButton( - onClick = { deleting = terminal.id }, - enabled = enabled, - modifier = Modifier.testTag("terminal-delete-${terminal.id}") - ) { - Icon( - Icons.Outlined.Delete, - contentDescription = "Delete terminal ${terminal.name}" - ) - } - if (state.deleting == terminal.id) Text("Deleting…") - } + TerminalListRow(terminal, state, onSelect, dialogs) } } } - deleting?.let { id -> +} + +@Composable +private fun TerminalListRow( + terminal: TerminalInfo, + state: TerminalState, + onSelect: (String) -> Unit, + dialogs: TerminalDialogState +) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + TextButton( + onClick = { onSelect(terminal.id) }, + enabled = !state.loading && state.deleting != terminal.id, + modifier = Modifier.weight(1f).testTag("terminal-open-${terminal.id}") + ) { + Text("${state.title(terminal)}\n${terminal.cwd}") + } + IconButton( + onClick = { + dialogs.name = state.title(terminal) + dialogs.renaming = terminal.id + }, + enabled = state.deleting != terminal.id, + modifier = Modifier.testTag("terminal-rename-${terminal.id}") + ) { + Icon( + Icons.Outlined.Edit, + contentDescription = "Rename terminal ${state.title(terminal)}" + ) + } + IconButton( + onClick = { dialogs.deleting = terminal.id }, + enabled = state.mutationsEnabled, + modifier = Modifier.testTag("terminal-delete-${terminal.id}") + ) { + Icon( + Icons.Outlined.Delete, + contentDescription = "Delete terminal ${state.title(terminal)}" + ) + } + if (state.deleting == terminal.id) Text("Deleting…") + } +} + +@Composable +private fun TerminalDialogs( + state: TerminalState, + dialogs: TerminalDialogState, + actions: TerminalListActions +) { + dialogs.renaming?.let { id -> + TerminalRenameDialog( + name = dialogs.name, + onNameChange = { dialogs.name = it }, + enabled = state.terminals.any { it.id == id } && state.deleting != id, + onDismiss = { dialogs.renaming = null }, + onConfirm = { + actions.rename(id, dialogs.name) + dialogs.renaming = null + } + ) + } + dialogs.deleting?.let { id -> TerminalDeleteConfirmation( - enabled = enabled && state.terminals.any { it.id == id }, - onDismiss = { deleting = null }, + enabled = state.mutationsEnabled && state.terminals.any { it.id == id }, + onDismiss = { dialogs.deleting = null }, onConfirm = { - deleting = null - onDelete(id) + dialogs.deleting = null + actions.delete(id) } ) } } +@Composable +private fun TerminalRenameDialog( + name: String, + onNameChange: (String) -> Unit, + enabled: Boolean, + onDismiss: () -> Unit, + onConfirm: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Rename terminal") }, + text = { + OutlinedTextField( + value = name, + onValueChange = onNameChange, + label = { Text("Terminal name") }, + supportingText = { + Text("Saved on this device. Leave empty to use the default name.") + }, + singleLine = true, + modifier = Modifier.testTag("terminal-name") + ) + }, + confirmButton = { + TextButton(enabled = enabled, onClick = onConfirm) { Text("Rename") } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } } + ) +} + @Composable private fun TerminalDeleteConfirmation( enabled: Boolean, diff --git a/app/src/main/java/dev/intent/app/terminals/TerminalNames.kt b/app/src/main/java/dev/intent/app/terminals/TerminalNames.kt new file mode 100644 index 0000000..e77406d --- /dev/null +++ b/app/src/main/java/dev/intent/app/terminals/TerminalNames.kt @@ -0,0 +1,32 @@ +package dev.intent.app.terminals + +import android.content.SharedPreferences + +/** Names belong to this device and a daemon boot, never to a reused PTY id. */ +interface TerminalNames { + fun get(bootId: String, terminalId: String): String? + fun set(bootId: String, terminalId: String, name: String?) +} + +internal class MemoryTerminalNames : TerminalNames { + private val names = mutableMapOf, String>() + override fun get(bootId: String, terminalId: String): String? = names[bootId to terminalId] + override fun set(bootId: String, terminalId: String, name: String?) { + if (name == null) names.remove(bootId to terminalId) else names[bootId to terminalId] = name + } +} + +internal class SavedTerminalNames( + private val preferences: SharedPreferences, + private val workspaceId: String +) : TerminalNames { + private fun key(bootId: String, terminalId: String) = + listOf(workspaceId, bootId, terminalId).joinToString(":") { "${it.length}:$it" } + + override fun get(bootId: String, terminalId: String): String? = + preferences.getString(key(bootId, terminalId), null) + + override fun set(bootId: String, terminalId: String, name: String?) { + preferences.edit().putString(key(bootId, terminalId), name).apply() + } +} diff --git a/app/src/main/java/dev/intent/app/terminals/TerminalStore.kt b/app/src/main/java/dev/intent/app/terminals/TerminalStore.kt index 1b72017..9fcef4c 100644 --- a/app/src/main/java/dev/intent/app/terminals/TerminalStore.kt +++ b/app/src/main/java/dev/intent/app/terminals/TerminalStore.kt @@ -21,12 +21,18 @@ data class TerminalState( val sending: Boolean = false, val ended: Boolean = false, val error: String? = null, - val notice: String? = null -) + val notice: String? = null, + val names: Map = emptyMap() +) { + fun title(terminal: TerminalInfo): String = names[terminal.id] ?: terminal.name +} /** One workspace subscription, with fenced reads and no input replay on reconnect. */ @Suppress("TooManyFunctions", "ReturnCount") -class TerminalStore(private val session: TerminalSession) : Closeable { +class TerminalStore( + private val session: TerminalSession, + private val names: TerminalNames = MemoryTerminalNames() +) : Closeable { private val mutableState = MutableStateFlow(TerminalState()) val state = mutableState.asStateFlow() val terminal = TerminalEngine(::send) @@ -92,6 +98,11 @@ class TerminalStore(private val session: TerminalSession) : Closeable { boot = result.daemonBootId mutableState.value = state.value.copy( terminals = result.terminals, + names = result.terminals.mapNotNull { terminal -> + names.get(result.daemonBootId, terminal.id)?.let { + terminal.id to it + } + }.toMap(), selected = selected, loading = false, connected = true, @@ -178,6 +189,26 @@ class TerminalStore(private val session: TerminalSession) : Closeable { } } + @Synchronized + fun rename(id: String, name: String) { + val bootId = boot ?: return + if (closed || + state.value.terminals.none { it.id == id } || + state.value.deleting == id + ) { + return + } + val customName = name.trim().takeIf(String::isNotEmpty) + names.set(bootId, id, customName) + mutableState.value = state.value.copy( + names = if (customName == null) { + state.value.names - id + } else { + state.value.names + (id to customName) + } + ) + } + private fun canMutate() = !closed && state.value.connected && !state.value.loading && diff --git a/app/src/main/java/dev/intent/app/terminals/WorkspaceTerminals.kt b/app/src/main/java/dev/intent/app/terminals/WorkspaceTerminals.kt index 8a31037..939b051 100644 --- a/app/src/main/java/dev/intent/app/terminals/WorkspaceTerminals.kt +++ b/app/src/main/java/dev/intent/app/terminals/WorkspaceTerminals.kt @@ -18,7 +18,9 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag import androidx.compose.ui.viewinterop.AndroidView import dev.intent.app.design.IntentScreen import dev.intent.app.design.IntentTextButton as TextButton @@ -35,8 +37,13 @@ fun WorkspaceTerminals( createOnOpen: Boolean = false, onCreateHandled: () -> Unit = {} ) { - val store = - remember(session, workspaceId) { TerminalStore(RpcTerminalSession(session, workspaceId)) } + val context = LocalContext.current + val store = remember(session, workspaceId) { + TerminalStore( + RpcTerminalSession(session, workspaceId), + SavedTerminalNames(context.getSharedPreferences("terminal-names", 0), workspaceId) + ) + } DisposableEffect(store) { store.start() onDispose { store.close() } @@ -58,7 +65,12 @@ fun WorkspaceTerminals( } ) if (state.selected == null) { - TerminalListScreen(state, store::refresh, store::select, store::create, store::delete) + TerminalListScreen( + state, + store::refresh, + store::select, + TerminalListActions(store::create, store::delete, store::rename) + ) } else { Column( Modifier.weight(1f).intentPageInsets().imePadding(), @@ -68,7 +80,10 @@ fun WorkspaceTerminals( state.actionError?.let { Text(it, color = IntentScreen.error) } state.notice?.let { Text(it) } if (state.loading) Text("Loading terminals…") - Text(state.terminals.firstOrNull { it.id == state.selected }?.name ?: "Terminal") + Text( + state.terminals.firstOrNull { it.id == state.selected }?.let(state::title) + ?: "Terminal" + ) TerminalSurface(store, state, Modifier.weight(1f)) if (state.ended) Text("This terminal has ended.") } @@ -104,6 +119,7 @@ internal fun TerminalSurface(store: TerminalStore, state: TerminalState, modifie } } var control by remember(canvas) { mutableStateOf(false) } + var expanded by remember(canvas) { mutableStateOf(false) } var fontSize by remember(canvas) { mutableStateOf(DEFAULT_FONT_SIZE) } Column(modifier) { AndroidView( @@ -121,32 +137,54 @@ internal fun TerminalSurface(store: TerminalStore, state: TerminalState, modifie TerminalKey("Esc", state.ready) { canvas.input("\u001b") } TerminalKey("Ctrl${if (control) " ✓" else ""}", state.ready) { control = !control } TerminalKey("Tab", state.ready) { canvas.input("\t") } - listOf("←" to 'D', "↑" to 'A', "↓" to 'B', "→" to 'C').forEach { (label, code) -> - TerminalKey(label, state.ready) { canvas.arrow(code) } - } - listOf("~", "|", "/", "-", "_", "$", "Enter").forEach { key -> - TerminalKey(key, state.ready) { canvas.input(if (key == "Enter") "\r" else key) } - } - } - Row(Modifier.horizontalScroll(rememberScrollState())) { TerminalKey("Keyboard", state.ready, canvas::toggleKeyboard) - TerminalKey("Copy output", true, canvas::copyOutput) - TerminalKey("Paste", state.ready, canvas::paste) - TerminalKey("A−", fontSize > MIN_FONT_SIZE) { - fontSize-- - canvas.fontSize(fontSize) - } - TerminalKey("A+", fontSize < MAX_FONT_SIZE) { - fontSize++ - canvas.fontSize(fontSize) + TerminalKey(if (expanded) "Less keys" else "More keys", true) { expanded = !expanded } + } + if (expanded) { + Row(Modifier.horizontalScroll(rememberScrollState()).testTag("terminal-extra-keys")) { + TerminalSymbolKeys(canvas, state.ready) + TerminalKey("Copy output", true, canvas::copyOutput) + TerminalKey("Paste", state.ready, canvas::paste) + TerminalKey("A−", fontSize > MIN_FONT_SIZE) { + fontSize-- + canvas.fontSize(fontSize) + } + TerminalKey("A+", fontSize < MAX_FONT_SIZE) { + fontSize++ + canvas.fontSize(fontSize) + } } } } } +@Composable +private fun TerminalSymbolKeys(canvas: TerminalCanvas, enabled: Boolean) { + listOf("←" to 'D', "↑" to 'A', "↓" to 'B', "→" to 'C').forEach { (label, code) -> + TerminalKey(label, enabled) { canvas.arrow(code) } + } + listOf("~", "|", "/", "-", "_", "$", "Enter").forEach { key -> + TerminalKey(key, enabled) { + canvas.input( + if (key == + "Enter" + ) { + "\r" + } else { + key + } + ) + } + } +} + @Composable private fun TerminalKey(label: String, enabled: Boolean, action: () -> Unit) { - TextButton(enabled = enabled, onClick = action) { Text(label) } + TextButton( + enabled = enabled, + onClick = action, + modifier = Modifier.focusProperties { canFocus = false } + ) { Text(label) } } private const val DEFAULT_FONT_SIZE = 12 diff --git a/app/src/main/java/dev/intent/app/workspaces/CreationAgentStep.kt b/app/src/main/java/dev/intent/app/workspaces/CreationAgentStep.kt index 4d4702b..bb5962b 100644 --- a/app/src/main/java/dev/intent/app/workspaces/CreationAgentStep.kt +++ b/app/src/main/java/dev/intent/app/workspaces/CreationAgentStep.kt @@ -40,6 +40,7 @@ internal fun CreationAgent( var loading by remember { mutableStateOf(true) } var modelsLoading by remember { mutableStateOf(false) } var error by remember { mutableStateOf(null) } + var modelError by remember { mutableStateOf(null) } LaunchedEffect(retry) { loading = true error = null @@ -65,7 +66,7 @@ internal fun CreationAgent( }.orEmpty(), specialist = current.specialist.takeIf { id -> loaded.specialists.any { it.id == id } } - ?: loaded.defaultSpecialist?.id.orEmpty() + .orEmpty() ) ) } @@ -82,6 +83,7 @@ internal fun CreationAgent( LaunchedEffect(draft.provider, retry) { models = emptyList() modelsLoading = true + modelError = null try { if (draft.provider.isNotBlank()) { models = @@ -97,7 +99,7 @@ internal fun CreationAgent( } catch (cancelled: kotlinx.coroutines.CancellationException) { throw cancelled } catch (failure: Exception) { - error = "Models could not load. Retry to use this provider." + modelError = "Models could not load. Retry to use this provider." } finally { modelsLoading = false @@ -114,10 +116,7 @@ internal fun CreationAgent( description.image ) } - val repositoryLabel = - draft.repository?.fullName.takeUnless { draft.newRepository } ?: draft.repositoryPath - if (repositoryLabel.isNotBlank()) Text(repositoryLabel) - if (!draft.newRepository && draft.baseRef.isNotBlank()) Text(draft.baseRef) + CreationSourceSummary(draft) IntentTextField(draft.title, { workflow.update(draft.copy(title = it)) }, label = { Text("Title (optional)") }, enabled = enabled) @@ -153,11 +152,16 @@ internal fun CreationAgent( } } if (loading || modelsLoading) Text("Loading agent choices…") - error?.let { Text(it) } - IntentTextButton(onClick = { - retry++ - }, enabled = enabled && !loading && !modelsLoading) { Text("Retry agent choices") } + (error ?: modelError)?.let { + Text(it) + IntentTextButton(onClick = { + retry++ + }, enabled = enabled && !loading && !modelsLoading) { Text("Retry agent choices") } + } IntentSection("Specialist") { + FilterChip(draft.specialist.isEmpty(), { + workflow.update(draft.copy(specialist = "")) + }, label = { Text("None") }, enabled = enabled) options?.specialists.orEmpty().forEach { choice -> FilterChip(draft.specialist == choice.id, { workflow.update(draft.copy(specialist = choice.id)) @@ -165,6 +169,9 @@ internal fun CreationAgent( } } IntentSection("Provider") { + if (!loading && error == null && options?.providers.orEmpty().none { it.selectable }) { + Text("No providers are ready. Enable a provider and sign in on the host.") + } options?.providers.orEmpty().forEach { choice -> FilterChip( draft.provider == choice.id, @@ -201,6 +208,7 @@ internal fun CreationAgent( !loading && !modelsLoading && error == null && + modelError == null && options?.providers.orEmpty().any { it.id == draft.provider && it.selectable } androidx.compose.runtime.SideEffect { onReady(enabled && ready && additions?.ready != false) } } diff --git a/app/src/main/java/dev/intent/app/workspaces/CreationRepositoryStore.kt b/app/src/main/java/dev/intent/app/workspaces/CreationRepositoryStore.kt index 7cfc4f5..74a15c4 100644 --- a/app/src/main/java/dev/intent/app/workspaces/CreationRepositoryStore.kt +++ b/app/src/main/java/dev/intent/app/workspaces/CreationRepositoryStore.kt @@ -6,6 +6,8 @@ import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.put +data class CreationBranchChoices(val branches: List, val defaultBranch: String) + class CreationRepositoryStore(private val operations: WorkspaceOperations) { fun repositories(query: String): List { val recent = if (query.isBlank()) { @@ -35,7 +37,10 @@ class CreationRepositoryStore(private val operations: WorkspaceOperations) { }.distinctBy { it.fullName } } - fun branches(repository: CreationRepository, query: String): List { + fun branches(repository: CreationRepository, query: String): List = + branchChoices(repository, query).branches + + fun branchChoices(repository: CreationRepository, query: String): CreationBranchChoices { val params = buildJsonObject { put("owner", repository.owner) put("repo", repository.name) @@ -51,13 +56,14 @@ class CreationRepositoryStore(private val operations: WorkspaceOperations) { operations.request("github.repos.get", params).obj("repo").string("defaultBranch") }.getOrDefault("") } - return ( + val choices = ( listOf(default).filter { it.isNotBlank() && (query.isBlank() || it.startsWith(query)) } + branches ).distinct() + return CreationBranchChoices(choices, default) } fun checkDestination(path: String) { diff --git a/app/src/main/java/dev/intent/app/workspaces/CreationSourceSteps.kt b/app/src/main/java/dev/intent/app/workspaces/CreationSourceSteps.kt index 161040c..0614292 100644 --- a/app/src/main/java/dev/intent/app/workspaces/CreationSourceSteps.kt +++ b/app/src/main/java/dev/intent/app/workspaces/CreationSourceSteps.kt @@ -3,7 +3,9 @@ package dev.intent.app.workspaces import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Badge import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -15,12 +17,15 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import dev.intent.app.design.IntentButton +import dev.intent.app.design.IntentScreen +import dev.intent.app.design.IntentSection import dev.intent.app.design.IntentTextField import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +@Suppress("LongMethod") // Repository search and owner groups share one selection state. @Composable internal fun CreationRepositories(workflow: CreationWorkflow, enabled: Boolean) { val draft by workflow.draft.collectAsState() @@ -43,7 +48,7 @@ internal fun CreationRepositories(workflow: CreationWorkflow, enabled: Boolean) loading = false } } - Text("Repository") + Text("Choose a repository", style = MaterialTheme.typography.titleMedium) IntentButton(onClick = { workflow.update(draft.copy(step = CreationStep.NEW_REPOSITORY, newRepository = true)) }, enabled = enabled) { Text("New repository") } @@ -52,14 +57,29 @@ internal fun CreationRepositories(workflow: CreationWorkflow, enabled: Boolean) }, label = { Text("Search repositories") }, modifier = Modifier.fillMaxWidth()) if (loading) Text("Loading repositories…") error?.let { - Text(it) + Text(it, color = IntentScreen.error) IntentButton(onClick = { retry++ }) { Text("Retry repositories") } } - if (!loading && error == null && rows.isEmpty()) Text("No repositories found.") - rows.forEach { repo -> - FilterChip(selected = draft.repository == repo, onClick = { - workflow.update(draft.select(repo)) - }, label = { Text(repo.fullName) }, enabled = enabled) + if (!loading && error == null && rows.isEmpty()) { + Text( + if (query.isBlank()) { + "No repositories available. Create one above or check GitHub sign-in on the host." + } else { + "No repositories match your search. Try another name or owner/repository." + }, + color = IntentScreen.secondaryText + ) + } + if (!loading && error == null) { + rows.groupBy { it.owner }.forEach { (owner, repositories) -> + IntentSection(owner) { + repositories.forEach { repo -> + FilterChip(selected = draft.repository?.fullName == repo.fullName, onClick = { + workflow.update(draft.select(repo)) + }, label = { Text(repo.name) }, enabled = enabled) + } + } + } } IntentButton( onClick = { workflow.update(draft.copy(step = CreationStep.BRANCH)) }, @@ -68,6 +88,8 @@ internal fun CreationRepositories(workflow: CreationWorkflow, enabled: Boolean) ) { Text("Choose branch") } } +// Branch search, default badge, and selection share one load. +@Suppress("LongMethod", "CyclomaticComplexMethod") @Composable internal fun CreationBranches(workflow: CreationWorkflow, enabled: Boolean) { val draft by workflow.draft.collectAsState() @@ -75,6 +97,7 @@ internal fun CreationBranches(workflow: CreationWorkflow, enabled: Boolean) { var query by remember { mutableStateOf("") } var retry by remember { mutableStateOf(0) } var rows by remember { mutableStateOf(emptyList()) } + var defaultBranch by remember(repository) { mutableStateOf(repository.defaultBranch) } var error by remember { mutableStateOf(null) } var loading by remember { mutableStateOf(true) } LaunchedEffect(repository, query, retry) { @@ -82,9 +105,13 @@ internal fun CreationBranches(workflow: CreationWorkflow, enabled: Boolean) { error = null delay(CREATION_SEARCH_DELAY) try { - rows = withContext(Dispatchers.IO) { workflow.repositories.branches(repository, query) } + val choices = withContext(Dispatchers.IO) { + workflow.repositories.branchChoices(repository, query) + } + rows = choices.branches + defaultBranch = choices.defaultBranch if (query.isBlank() && - draft.baseRef !in rows + workflow.draft.value.baseRef !in rows ) { workflow.update( workflow.draft.value.copy(baseRef = rows.firstOrNull().orEmpty()) @@ -99,19 +126,46 @@ internal fun CreationBranches(workflow: CreationWorkflow, enabled: Boolean) { false } } - Text(repository.fullName) + CreationSourceSummary(draft) IntentTextField(query, { query = it }, label = { Text("Search branches") }, modifier = Modifier.fillMaxWidth()) if (loading) Text("Loading branches…") error?.let { - Text(it) + Text(it, color = IntentScreen.error) IntentButton(onClick = { retry++ }) { Text("Retry branches") } } - rows.forEach { branch -> - FilterChip(selected = draft.baseRef == branch, onClick = { - workflow.update(draft.copy(baseRef = branch)) - }, label = { Text(branch) }, enabled = enabled) + IntentSection("Branches") { + if (!loading && error == null && rows.isEmpty()) { + Text( + if (query.isBlank()) { + "This repository has no branches. Push an initial commit on the host, then reopen this step." + } else { + "No branches match your search. Try a different branch name." + }, + color = IntentScreen.secondaryText + ) + } + if (!loading && error == null) { + rows.forEach { branch -> + FilterChip( + selected = draft.baseRef == branch, + onClick = { workflow.update(draft.copy(baseRef = branch)) }, + label = { Text(branch) }, + trailingIcon = if (branch == defaultBranch) { + { + Badge( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = IntentScreen.secondaryText + ) { Text("Default") } + } + } else { + null + }, + enabled = enabled + ) + } + } } IntentButton( onClick = { workflow.update(draft.copy(step = CreationStep.AGENT)) }, @@ -176,3 +230,21 @@ internal fun CreationNewRepository(workflow: CreationWorkflow, enabled: Boolean) } private const val CREATION_SEARCH_DELAY = 250L + +@Composable +internal fun CreationSourceSummary(draft: WorkspaceDraft) { + IntentSection("Source") { + Text( + if (draft.newRepository) draft.repositoryPath else draft.repository?.fullName.orEmpty(), + style = MaterialTheme.typography.bodyLarge + ) + Text( + if (draft.newRepository) { + "New repository on this host" + } else { + "Branch: ${draft.baseRef.ifBlank { "Choose a branch" }}" + }, + color = IntentScreen.secondaryText + ) + } +} diff --git a/app/src/main/java/dev/intent/app/workspaces/CreationWorkflow.kt b/app/src/main/java/dev/intent/app/workspaces/CreationWorkflow.kt index df97bf5..314abe4 100644 --- a/app/src/main/java/dev/intent/app/workspaces/CreationWorkflow.kt +++ b/app/src/main/java/dev/intent/app/workspaces/CreationWorkflow.kt @@ -28,7 +28,10 @@ class CreationWorkflow( require(catalog.providers.any { it.id == current.provider && it.selectable }) { "Choose a signed-in provider. Refresh agent choices." } - require(catalog.specialists.any { it.id == current.specialist }) { + require( + current.specialist.isBlank() || + catalog.specialists.any { it.id == current.specialist } + ) { "Choose a specialist. Refresh agent choices." } val models = options.models(current.provider) diff --git a/app/src/main/java/dev/intent/app/workspaces/TransferEligibility.kt b/app/src/main/java/dev/intent/app/workspaces/TransferEligibility.kt new file mode 100644 index 0000000..2a6fe1d --- /dev/null +++ b/app/src/main/java/dev/intent/app/workspaces/TransferEligibility.kt @@ -0,0 +1,24 @@ +package dev.intent.app.workspaces + +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.longOrNull + +/** Current principal.me reply only; saved connection metadata never grants transfer access. */ +@Suppress("ReturnCount") // Unknown or inconsistent authority is never eligible. +internal fun transferPrincipalIsOwner(principal: JsonObject, capabilities: JsonObject): Boolean { + val id = principal["id"] as? JsonPrimitive + if (id?.isString != true || id.contentOrNull.isNullOrBlank()) return false + if (principal["isAdministrator"] != JsonPrimitive(true)) return false + val membership = capabilities["hostMembership"] == JsonPrimitive(1) + val role = principal["hostRole"] + if (role != null && role != JsonPrimitive("owner")) return false + if (!membership) return true + val revision = principal["hostMembershipRevision"] as? JsonPrimitive + return role == JsonPrimitive("owner") && + revision?.isString == false && + revision.longOrNull?.let { it in 0..MAX_MEMBERSHIP_REVISION } == true +} + +private const val MAX_MEMBERSHIP_REVISION = 9_007_199_254_740_991L diff --git a/app/src/main/java/dev/intent/app/workspaces/WorkspaceDetailScreen.kt b/app/src/main/java/dev/intent/app/workspaces/WorkspaceDetailScreen.kt index ddb9f9e..3b6cb01 100644 --- a/app/src/main/java/dev/intent/app/workspaces/WorkspaceDetailScreen.kt +++ b/app/src/main/java/dev/intent/app/workspaces/WorkspaceDetailScreen.kt @@ -1,15 +1,34 @@ package dev.intent.app.workspaces +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import dev.intent.app.attachments.MediaPreview import dev.intent.app.design.IntentScreen import dev.intent.app.design.IntentSpacing import dev.intent.app.design.IntentTabs +import dev.intent.app.design.IntentTextButton import dev.intent.app.navigation.ChromeAction import dev.intent.app.navigation.CompactAppBar import dev.intent.app.navigation.LocalWorkspaceTitle @@ -23,38 +42,118 @@ internal fun WorkspaceDetailScreen( actions: List = emptyList(), open: (WorkspaceDestination) -> Unit ) { - val sections = listOf( - "Agents" to WorkspaceDestination.Conversations, - "Context" to WorkspaceDestination.Notes, - "Changes" to WorkspaceDestination.Git - ) + val sections = + listOf( + "Agents" to WorkspaceDestination.Conversations, + "Context" to WorkspaceDestination.Notes, + "Changes" to WorkspaceDestination.Git + ) val selectedIndex = sections.indexOfFirst { it.second == selected }.coerceAtLeast(0) + var showDetails by rememberSaveable(workspace?.id) { mutableStateOf(false) } + val configuration = LocalConfiguration.current + val compact = configuration.screenWidthDp > configuration.screenHeightDp + val chromeActions = if (compact && workspace != null) { + listOf(ChromeAction("Workspace details") { showDetails = true }) + actions + } else { + actions + } Column { - CompactAppBar(workspace?.title ?: LocalWorkspaceTitle.current, actions = actions) - Column(Modifier.padding(horizontal = IntentScreen.sectionInsets)) { - workspace?.statusMessage?.takeIf { it.isNotBlank() }?.let { - Text(it, maxLines = 2, overflow = TextOverflow.Ellipsis) - } - Text( - workspace?.let { workspaceStateLabel(it) } ?: "Workspace state unavailable", - style = MaterialTheme.typography.labelMedium, - color = IntentScreen.secondaryText - ) - } + CompactAppBar( + if (compact) workspace?.title ?: LocalWorkspaceTitle.current else "", + actions = chromeActions + ) + if (!compact) WorkspaceHeading(workspace, onDetails = { showDetails = true }) IntentTabs( titles = sections.map { it.first }, selectedIndex = selectedIndex, onSelect = { open(sections[it].second) }, - modifier = Modifier.padding( + modifier = + Modifier.padding( horizontal = IntentSpacing.page, vertical = IntentSpacing.small ) ) } + if (showDetails && workspace != null) WorkspaceSummaryDialog(workspace) { showDetails = false } +} + +@Composable +private fun WorkspaceHeading(workspace: Workspace?, onDetails: () -> Unit) { + val largeText = LocalDensity.current.fontScale > LARGE_TEXT_SCALE + Column( + Modifier.padding(horizontal = IntentScreen.sectionInsets), + verticalArrangement = Arrangement.spacedBy(IntentSpacing.small) + ) { + Text( + workspace?.title ?: LocalWorkspaceTitle.current, + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.semantics { heading() }, + maxLines = if (largeText) 2 else 3, + overflow = TextOverflow.Ellipsis + ) + if (!largeText) { + workspace?.let(::workspaceRepository)?.let { + Text( + it, + style = MaterialTheme.typography.bodySmall, + color = IntentScreen.secondaryText + ) + } + } + Text( + workspace?.let(::workspaceStateLabel) ?: "Workspace state unavailable", + style = MaterialTheme.typography.labelMedium, + color = IntentScreen.secondaryText + ) + if (!largeText && workspace != null) WorkspaceSummaryPreview(workspace) + if (workspace != null) { + IntentTextButton(onClick = onDetails) { Text("Workspace details") } + } + } +} + +@Composable +private fun WorkspaceSummaryPreview(workspace: Workspace) { + Row(horizontalArrangement = Arrangement.spacedBy(IntentSpacing.medium)) { + workspace.statusMessage?.takeIf { it.isNotBlank() }?.let { + Text(it, Modifier.weight(1f), maxLines = 2, overflow = TextOverflow.Ellipsis) + } + workspaceStatusImage(workspace)?.let { + Box(Modifier.size(64.dp).clipToBounds()) { + MediaPreview(it, actions = false, showName = false) + } + } + } +} + +@Composable +private fun WorkspaceSummaryDialog(workspace: Workspace, onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Workspace details") }, + text = { + Column( + Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(IntentSpacing.medium) + ) { + Text( + workspace.title, + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.semantics { heading() } + ) + workspaceRepository(workspace)?.let { Text(it) } + Text(workspaceStateLabel(workspace)) + workspacePrLabel(workspace)?.let { Text(it) } + workspaceSignals(workspace).forEach { Text(it) } + workspace.statusMessage?.takeIf { it.isNotBlank() }?.let { Text(it) } + workspaceStatusImage(workspace)?.let { MediaPreview(it, showName = false) } + workspaceActivityLabel(workspace)?.let { + Text(it, style = MaterialTheme.typography.bodySmall) + } + } + }, + confirmButton = { IntentTextButton(onClick = onDismiss) { Text("Done") } } + ) } -internal fun workspaceStateLabel(workspace: Workspace): String = listOfNotNull( - workspace.status.takeIf { it.isNotBlank() }, - workspace.displayStatus?.takeIf { it.isNotBlank() }?.replace('_', ' ') - ?.replaceFirstChar { it.titlecase() }?.replace("Pr ", "PR ") -).distinctBy { it.lowercase() }.joinToString(" · ").ifBlank { "Workspace state unavailable" } +private const val LARGE_TEXT_SCALE = 1.3f diff --git a/app/src/main/java/dev/intent/app/workspaces/WorkspaceListScreen.kt b/app/src/main/java/dev/intent/app/workspaces/WorkspaceListScreen.kt index f9028f2..2bf16e7 100644 --- a/app/src/main/java/dev/intent/app/workspaces/WorkspaceListScreen.kt +++ b/app/src/main/java/dev/intent/app/workspaces/WorkspaceListScreen.kt @@ -12,9 +12,16 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn +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.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Search import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -25,10 +32,15 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf 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.drawWithCache +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -36,10 +48,11 @@ import dev.intent.app.design.IntentColors import dev.intent.app.design.IntentLightDarkPreview import dev.intent.app.design.IntentScreen import dev.intent.app.design.IntentSpacing +import dev.intent.app.design.IntentTabs import dev.intent.app.design.IntentTheme import dev.intent.app.lists.RefreshableList import dev.intent.app.navigation.ChromeAction -import dev.intent.app.navigation.CompactAppBar +import dev.intent.app.navigation.ChromeActionsMenu import dev.intent.core.protocol.models.Workspace @Suppress("LongParameterList", "LongMethod") @@ -82,47 +95,83 @@ fun WorkspaceListScreen( var query by rememberSaveable { mutableStateOf("") } var showArchived by rememberSaveable { mutableStateOf(false) } var revealedId by rememberSaveable { mutableStateOf(null) } - val rows = state.workspaces.filter { - (showArchived || !it.status.equals("Archived", true)) && - ( - it.title.contains(query.trim(), true) || - it.statusMessage.orEmpty().contains(query.trim(), true) - ) - } + var grouping by rememberSaveable { mutableStateOf(WorkspaceGrouping.Recent) } + val groups = workspaceGroups(state.workspaces, query, showArchived, grouping) + val listState = rememberLazyListState() + val canvas = IntentScreen.canvas val drawerButtons = dev.intent.app.drawers.LocalDrawerButtons.current Column(modifier) { - CompactAppBar( - "Workspaces", - leading = drawerButtons?.let { - { DrawerHeaderButton("Connections", "☰", it.connections) } - }, - trailing = drawerButtons?.let { - { DrawerHeaderButton("Agent history", "◷", it.history) } - }, - actions = listOf( - ChromeAction(if (showArchived) "Hide archived" else "Show archived") { - showArchived = !showArchived - revealedId = null + Row( + Modifier.fillMaxWidth() + .then(dev.intent.app.drawers.LocalHeaderDrawerGesture.current) + .padding(IntentScreen.pageInsets), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + "Workspaces", + style = MaterialTheme.typography.headlineLarge, + modifier = Modifier.weight(1f).semantics { + heading() + drawerButtons?.let { + customActions = listOf( + CustomAccessibilityAction("Connections") { + it.connections() + true + }, + CustomAccessibilityAction("Agent history") { + it.history() + true + } + ) + } } ) - ) + ChromeActionsMenu( + listOf( + ChromeAction(if (showArchived) "Hide archived" else "Show archived") { + showArchived = !showArchived + revealedId = null + } + ) + ) + } dev.intent.app.lists.SearchableList( query, { query = it + revealedId = null }, "Search workspaces", state.workspaces.isNotEmpty(), - launcher = dev.intent.app.navigation.LocalLauncherActions.current + launcher = dev.intent.app.navigation.LocalLauncherActions.current, + controls = { searching, toggleSearch -> + Row( + Modifier.fillMaxWidth().padding(IntentScreen.pageInsets), + horizontalArrangement = Arrangement.spacedBy(IntentSpacing.row), + verticalAlignment = Alignment.CenterVertically + ) { + IntentTabs( + WorkspaceGrouping.entries.map { it.label }, + grouping.ordinal, + { + grouping = WorkspaceGrouping.entries[it] + revealedId = null + }, + Modifier.weight(1f) + ) + WorkspaceSearchButton(searching, state.workspaces.isNotEmpty(), toggleSearch) + } + } ) { contentPadding -> RefreshableList(state.isLoading, onRefresh) { LazyColumn( - Modifier.fillMaxSize(), + Modifier.fillMaxSize().workspaceTopFade(listState, canvas), + state = listState, contentPadding = contentPadding, verticalArrangement = Arrangement.spacedBy(0.dp) ) { state.error?.let { item { Text(it, color = MaterialTheme.colorScheme.error) } } - if (!state.isLoading && state.error == null && rows.isEmpty()) { + if (!state.isLoading && state.error == null && groups.isEmpty()) { item { Text( if (query.isBlank()) { @@ -133,16 +182,31 @@ fun WorkspaceListScreen( ) } } - items(rows, key = { it.id }) { workspace -> - WorkspaceSwipeRow( - workspace = workspace, - enabled = updatingId != workspace.id, - revealed = revealedId == workspace.id, - onReveal = { revealedId = if (it) workspace.id else null }, - onClick = { onWorkspaceSelected(workspace.id) }, - onOpenRecent = { onOpenRecent(workspace.id) }, - onAction = { onAction(workspace, it) } - ) { WorkspaceRow(workspace) } + groups.forEach { group -> + group.title?.let { title -> + item(key = "group:$title") { + Text( + title, + style = MaterialTheme.typography.titleSmall, + color = IntentScreen.secondaryText, + modifier = + Modifier + .padding(vertical = IntentSpacing.medium) + .semantics { heading() } + ) + } + } + items(group.workspaces, key = { "workspace:${it.id}" }) { workspace -> + WorkspaceSwipeRow( + workspace = workspace, + enabled = updatingId != workspace.id, + revealed = revealedId == workspace.id, + onReveal = { revealedId = if (it) workspace.id else null }, + onClick = { onWorkspaceSelected(workspace.id) }, + onOpenRecent = { onOpenRecent(workspace.id) }, + onAction = { onAction(workspace, it) } + ) { WorkspaceRow(workspace) } + } } } } @@ -150,11 +214,24 @@ fun WorkspaceListScreen( } } +private fun Modifier.workspaceTopFade(state: LazyListState, canvas: Color) = drawWithCache { + val height = IntentSpacing.page.toPx() + val brush = Brush.verticalGradient(listOf(canvas, canvas.copy(alpha = 0f)), endY = height) + onDrawWithContent { + drawContent() + val opacity = if (state.firstVisibleItemIndex > 0) { + 1f + } else { + (state.firstVisibleItemScrollOffset / height).coerceIn(0f, 1f) + } + if (opacity > 0f) drawRect(brush, size = Size(size.width, height), alpha = opacity) + } +} + @Composable private fun WorkspaceRow(workspace: Workspace) { Surface( modifier = Modifier.fillMaxWidth(), - shape = RectangleShape, color = IntentScreen.canvas ) { Column { @@ -163,8 +240,13 @@ private fun WorkspaceRow(workspace: Workspace) { horizontalArrangement = Arrangement.spacedBy(IntentSpacing.page) ) { Box( - Modifier.padding(top = IntentSpacing.small).size(12.dp) - .background(workspaceStatusColor(workspace.displayStatus), CircleShape) + Modifier + .padding(top = IntentSpacing.small) + .size(12.dp) + .background( + workspaceStatusColor(workspaceDisplayStatus(workspace)), + CircleShape + ) ) Column( Modifier.weight(1f), @@ -176,29 +258,20 @@ private fun WorkspaceRow(workspace: Workspace) { maxLines = 2, overflow = TextOverflow.Ellipsis ) - workspace.statusMessage?.takeIf { it.isNotBlank() }?.let { + workspaceRepository(workspace)?.let { Text( it, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 2, - overflow = TextOverflow.Ellipsis + style = MaterialTheme.typography.bodySmall, + color = IntentScreen.secondaryText ) } - FlowRow( - horizontalArrangement = Arrangement.spacedBy(IntentSpacing.medium), - verticalArrangement = Arrangement.spacedBy(IntentSpacing.small) - ) { - val showLifecycle = !workspace.status.equals("Active", true) - if (showLifecycle) { - WorkspaceBadge(workspace.status) - } - workspace.displayStatus?.takeIf { it.isNotBlank() }?.let { status -> - WorkspaceBadge( - status.replace('_', ' ').replaceFirstChar { it.titlecase() } - .replace("Pr ", "PR ") - ) - } + WorkspaceRowBadges(workspace) + workspaceActivityLabel(workspace)?.let { + Text( + it, + style = MaterialTheme.typography.labelSmall, + color = IntentScreen.secondaryText + ) } } } @@ -208,11 +281,28 @@ private fun WorkspaceRow(workspace: Workspace) { } @Composable -private fun workspaceStatusColor(status: String?): Color = when (status?.lowercase()) { - "working", "active", "running", "implementing" -> IntentColors.statusWorking - "complete", "completed", "pr_ready", "pr_merged" -> IntentColors.statusComplete - "blocked", "blocker", "question", "needs_attention" -> IntentColors.statusAttention - "failed", "error" -> MaterialTheme.colorScheme.error +private fun WorkspaceRowBadges(workspace: Workspace) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(IntentSpacing.medium), + verticalArrangement = Arrangement.spacedBy(IntentSpacing.small) + ) { + if (!workspace.status.equals("Active", true)) WorkspaceBadge(workspace.status) + WorkspaceBadge(workspaceDisplayStatus(workspace).label) + workspacePrLabel(workspace)?.let { WorkspaceBadge(it) } + workspaceSignals(workspace).forEach { WorkspaceBadge(it) } + } +} + +@Composable +private fun workspaceStatusColor(status: WorkspaceDisplayStatus): Color = when (status) { + WorkspaceDisplayStatus.InProgress -> IntentColors.statusWorking + WorkspaceDisplayStatus.Complete -> IntentColors.statusComplete + WorkspaceDisplayStatus.Blocked, WorkspaceDisplayStatus.NeedsAttention -> + IntentColors.statusAttention + WorkspaceDisplayStatus.Failed -> MaterialTheme.colorScheme.error + WorkspaceDisplayStatus.PrOpen, WorkspaceDisplayStatus.PrReady, + WorkspaceDisplayStatus.PrQueued, WorkspaceDisplayStatus.PrMerged + -> MaterialTheme.colorScheme.tertiary else -> MaterialTheme.colorScheme.onSurfaceVariant } @@ -226,7 +316,8 @@ private fun WorkspaceBadge(label: String) { ) { Text( label, - modifier = Modifier.padding( + modifier = + Modifier.padding( horizontal = IntentSpacing.medium, vertical = IntentSpacing.small ), @@ -270,9 +361,16 @@ private fun WorkspaceListPreview() { } @Composable -private fun DrawerHeaderButton(label: String, symbol: String, onClick: () -> Unit) { - androidx.compose.material3.IconButton( - onClick = onClick, - modifier = Modifier.semantics { contentDescription = label } - ) { Text(symbol) } +private fun WorkspaceSearchButton( + searching: Boolean, + hasWorkspaces: Boolean, + onToggle: () -> Unit +) { + if (!searching && !hasWorkspaces) return + IconButton(onClick = onToggle) { + Icon( + if (searching) Icons.Default.Close else Icons.Default.Search, + if (searching) "Close workspace search" else "Search workspaces" + ) + } } diff --git a/app/src/main/java/dev/intent/app/workspaces/WorkspacePresentation.kt b/app/src/main/java/dev/intent/app/workspaces/WorkspacePresentation.kt new file mode 100644 index 0000000..6913586 --- /dev/null +++ b/app/src/main/java/dev/intent/app/workspaces/WorkspacePresentation.kt @@ -0,0 +1,144 @@ +package dev.intent.app.workspaces + +import dev.intent.app.attachments.MediaReference +import dev.intent.app.attachments.WorkspaceAsset +import dev.intent.core.protocol.models.Workspace +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle + +internal enum class WorkspaceGrouping(val label: String) { + Recent("Recent"), + Repo("Repo"), + Status("Status") +} + +/** Presentation order only. The daemon owns status derivation (protocol §5.1). */ +internal enum class WorkspaceDisplayStatus(val wire: String, val label: String) { + Failed("failed", "Failed"), + Blocked("blocked", "Blocked"), + NeedsAttention("needs_attention", "Needs attention"), + Idle("idle", "Idle"), + NotStarted("not_started", "No code changes"), + InProgress("in_progress", "In progress"), + Complete("complete", "Complete"), + PrOpen("pr_open", "PR open"), + PrReady("pr_ready", "PR mergeable"), + PrQueued("pr_queued", "PR queued"), + PrMerged("pr_merged", "PR merged"), + Unknown("", "Unknown status") +} + +internal fun workspaceDisplayStatus(workspace: Workspace): WorkspaceDisplayStatus = + when (val status = workspace.displayStatus) { + null -> WorkspaceDisplayStatus.NotStarted + "unread" -> WorkspaceDisplayStatus.Idle // Retired wire value from older daemons. + else -> + WorkspaceDisplayStatus.entries.firstOrNull { it.wire == status && status.isNotEmpty() } + ?: WorkspaceDisplayStatus.Unknown + } + +internal fun workspaceRepository(workspace: Workspace): String? { + val name = workspace.repositoryName?.takeIf { it.isNotBlank() } ?: return null + val owner = workspace.repositoryOwner?.takeIf { it.isNotBlank() } + return if (owner == null) name else "$owner/$name" +} + +internal data class WorkspaceGroup(val title: String?, val workspaces: List) + +internal fun workspaceGroups( + workspaces: List, + query: String, + showArchived: Boolean, + grouping: WorkspaceGrouping +): List { + val search = query.trim() + val rows = + workspaces + .filter { workspace -> + !workspace.status.equals("Deleted", true) && + ( + showArchived || + !(workspace.archived || workspace.status.equals("Archived", true)) + ) && + listOfNotNull( + workspace.title, + workspace.statusMessage, + workspaceRepository(workspace) + ) + .any { it.contains(search, ignoreCase = true) } + }.sortedByDescending(::workspaceActivityTime) + return when (grouping) { + WorkspaceGrouping.Recent -> if (rows.isEmpty()) { + emptyList() + } else { + listOf( + WorkspaceGroup(null, rows) + ) + } + WorkspaceGrouping.Repo -> + rows + .groupBy { workspaceRepository(it) ?: "No repository" } + .map { (title, members) -> WorkspaceGroup(title, members) } + WorkspaceGrouping.Status -> + rows + .groupBy(::workspaceDisplayStatus) + .toSortedMap() + .map { (status, members) -> WorkspaceGroup(status.label, members) } + } +} + +/** Metadata writes must not reorder workspaces ahead of actual activity or creation. */ +internal fun workspaceActivityTime(workspace: Workspace): Instant = + listOf(workspace.lastActivity, workspace.createdAt, workspace.updatedAt) + .firstNotNullOfOrNull { raw -> + raw?.let { + runCatching { Instant.parse(it) }.getOrNull() + }?.takeIf { it > Instant.EPOCH } + } ?: Instant.EPOCH + +internal fun workspaceActivityLabel(workspace: Workspace): String? = + workspaceActivityTime(workspace).takeIf { it != Instant.EPOCH }?.let { + val formatted = + DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT) + .withZone(ZoneId.systemDefault()) + .format(it) + "Activity · $formatted" + } + +internal fun workspaceSignals(workspace: Workspace): List = buildList { + if (workspace.activity == "agent_running") add("Agent running") + when (workspace.attention) { + "unread" -> add("Unread updates") + "review_required" -> add("Review required") + } + if (workspace.waiting) add("Waiting") + if (workspace.scriptsRunning) add("Scripts running") +} + +internal fun workspacePrLabel(workspace: Workspace): String? = workspace.prNumber + ?.takeIf { it > 0 } + ?.let { number -> + val status = + workspace.prStatus + ?.takeIf { it.isNotBlank() } + ?.replace('_', ' ') + ?.replaceFirstChar { it.titlecase() } + listOfNotNull("PR #$number", status).joinToString(" · ") + } + +internal fun workspaceStateLabel(workspace: Workspace): String = listOfNotNull( + workspace.status.takeIf { it.isNotBlank() }, + workspaceDisplayStatus(workspace).label +).joinToString(" · ") + +internal fun workspaceStatusImage(workspace: Workspace): MediaReference? = + workspace.statusImageAssetId + ?.takeIf { it.isNotBlank() } + ?.let { assetId -> + WorkspaceAsset(workspace.id, assetId).takeIf { it.isSafe() }?.let { asset -> + MediaReference(name = "Workspace status image", image = true, asset = asset) + } + } diff --git a/app/src/main/java/dev/intent/app/workspaces/WorkspaceSwipeRow.kt b/app/src/main/java/dev/intent/app/workspaces/WorkspaceSwipeRow.kt index b67a8b7..f40c9f1 100644 --- a/app/src/main/java/dev/intent/app/workspaces/WorkspaceSwipeRow.kt +++ b/app/src/main/java/dev/intent/app/workspaces/WorkspaceSwipeRow.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.width import androidx.compose.material3.DropdownMenu @@ -45,7 +46,9 @@ internal fun WorkspaceSwipeRow( onAction: (WorkspaceAction) -> Unit, content: @Composable () -> Unit ) { - val width = with(LocalDensity.current) { ACTION_WIDTH.dp.toPx() } + val density = LocalDensity.current + val actionWidth = (ACTION_WIDTH * density.fontScale.coerceIn(1f, MAX_ACTION_FONT_SCALE)).dp + val width = with(density) { actionWidth.toPx() } var drag by remember(workspace.id, revealed) { mutableFloatStateOf(0f) } var menu by remember(workspace.id) { mutableStateOf(false) } val select: (WorkspaceAction) -> Unit = { @@ -53,16 +56,17 @@ internal fun WorkspaceSwipeRow( onReveal(false) onAction(it) } - Box(Modifier.clipToBounds()) { + Box(Modifier.heightIn(min = 96.dp).clipToBounds()) { if (revealed || drag < 0) { - Column(Modifier.align(Alignment.CenterEnd).width(ACTION_WIDTH.dp)) { + Column(Modifier.align(Alignment.CenterEnd).width(actionWidth)) { listOf(WorkspaceAction.Archive, WorkspaceAction.Delete).forEach { action -> TextButton(onClick = { select(action) }, enabled = enabled, modifier = Modifier.fillMaxWidth()) { Text( action.label, - color = if (action == + color = + if (action == WorkspaceAction.Delete ) { IntentScreen.error @@ -76,13 +80,16 @@ internal fun WorkspaceSwipeRow( } if (drag > 0) Text("Open recent agent", Modifier.align(Alignment.CenterStart)) Box( - Modifier.offset { - IntOffset( - ((if (revealed) -width else 0f) + drag).coerceIn(-width, width).roundToInt(), - 0 - ) - } - .testTag("workspace.row.${workspace.id}") + Modifier + .offset { + IntOffset( + ((if (revealed) -width else 0f) + drag).coerceIn( + -width, + width + ).roundToInt(), + 0 + ) + }.testTag("workspace.row.${workspace.id}") .pointerInput(workspace.id, enabled, revealed) { detectHorizontalDragGestures( onDragEnd = { @@ -104,29 +111,28 @@ internal fun WorkspaceSwipeRow( if (enabled) drag = (drag + amount).coerceIn(-width, width) } ) - } - .combinedClickable( + }.combinedClickable( enabled = enabled, onClick = { if (revealed) onReveal(false) else onClick() }, onLongClickLabel = "Workspace actions", onLongClick = { menu = true } - ) - .semantics { + ).semantics(mergeDescendants = true) { if (enabled) { - customActions = listOf( - CustomAccessibilityAction("Open most recent agent") { - onOpenRecent() - true - }, - CustomAccessibilityAction("Archive workspace") { - select(WorkspaceAction.Archive) - true - }, - CustomAccessibilityAction("Delete workspace") { - select(WorkspaceAction.Delete) - true - } - ) + customActions = + listOf( + CustomAccessibilityAction("Open most recent agent") { + onOpenRecent() + true + }, + CustomAccessibilityAction("Archive workspace") { + select(WorkspaceAction.Archive) + true + }, + CustomAccessibilityAction("Delete workspace") { + select(WorkspaceAction.Delete) + true + } + ) } } ) { content() } @@ -145,7 +151,8 @@ internal fun WorkspaceSwipeRow( text = { Text( "${action.label} workspace", - color = if (action == + color = + if (action == WorkspaceAction.Delete ) { IntentScreen.error @@ -164,4 +171,6 @@ internal fun WorkspaceSwipeRow( private const val ACTION_WIDTH = 88 +private const val MAX_ACTION_FONT_SCALE = 1.5f + private const val REVEAL_THRESHOLD = 0.333f diff --git a/app/src/main/java/dev/intent/app/workspaces/WorkspaceTransferScreen.kt b/app/src/main/java/dev/intent/app/workspaces/WorkspaceTransferScreen.kt index 4d2f699..26a0f2f 100644 --- a/app/src/main/java/dev/intent/app/workspaces/WorkspaceTransferScreen.kt +++ b/app/src/main/java/dev/intent/app/workspaces/WorkspaceTransferScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Checkbox +import androidx.compose.material3.FilterChip import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -40,6 +41,7 @@ import dev.intent.core.protocol.session.RpcSession import dev.intent.core.protocol.session.SessionState import java.util.concurrent.CompletableFuture import java.util.concurrent.TimeUnit +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.launch @@ -49,7 +51,8 @@ import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.longOrNull -@Suppress("LongMethod", "LongParameterList") // Keep session injection separate from navigation. +// Keep session injection separate from navigation; failures retain the review form. +@Suppress("LongMethod", "LongParameterList", "CyclomaticComplexMethod", "TooGenericExceptionCaught") @Composable fun WorkspaceTransferScreen( source: RpcSession, @@ -57,6 +60,9 @@ fun WorkspaceTransferScreen( destinations: List, onBusy: (Boolean) -> Unit, onDone: () -> Unit = {}, + workspaceTitle: String = workspaceId, + sourceName: String = "Connected host", + sourceState: SessionState = SessionState.DISCONNECTED, createTarget: ( SavedConnection, (SessionState) -> Unit @@ -65,13 +71,20 @@ fun WorkspaceTransferScreen( } ) { val operations = remember(source) { RpcWorkspaceOperations(source) } - var plan by remember { mutableStateOf(null) } + var plan by remember(source, workspaceId) { mutableStateOf(null) } var status by remember { mutableStateOf("Loading transfer preview…") } var busy by remember { mutableStateOf(false) } var finished by remember { mutableStateOf(false) } var archive by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() - val control = remember(source, workspaceId) { TransferControl() } + var attempt by remember(source, workspaceId) { mutableStateOf(0) } + var retryPreview by remember(source, workspaceId) { mutableStateOf(0) } + var previewFailed by remember(source, workspaceId) { mutableStateOf(false) } + var previewLoading by remember(source, workspaceId) { mutableStateOf(true) } + var owner by remember(source, sourceState) { mutableStateOf(false) } + var destinationId by remember(source, workspaceId) { mutableStateOf(null) } + val selectedDestination = destinations.firstOrNull { it.id == destinationId } + val control = remember(source, workspaceId, attempt) { TransferControl() } val progress by control.state.collectAsState() val lifecycle = LocalLifecycleOwner.current.lifecycle val active by rememberUpdatedState(busy) @@ -86,9 +99,31 @@ fun WorkspaceTransferScreen( } } BackHandler(busy) { } - LaunchedEffect(source, workspaceId) { - runCatching { - withContext(Dispatchers.IO) { + LaunchedEffect(source, workspaceId, sourceState, retryPreview, attempt) { + if (busy || finished) return@LaunchedEffect + plan = null + owner = false + previewFailed = false + previewLoading = true + status = "Loading transfer preview…" + if (sourceState != SessionState.READY) { + status = "Reconnect to the source host to preview this transfer." + previewLoading = false + return@LaunchedEffect + } + try { + owner = withContext(Dispatchers.IO) { + transferPrincipalIsOwner( + operations.request("principal.me"), + source.serverCapabilities + ) + } + if (!owner) { + status = + "Only a host owner can transfer a workspace. Check your access on the host." + return@LaunchedEffect + } + plan = withContext(Dispatchers.IO) { operations.request( "workspace.transfer.plan", kotlinx.serialization.json.buildJsonObject { @@ -96,11 +131,15 @@ fun WorkspaceTransferScreen( } ).obj("plan") } - }.onSuccess { - plan = it status = "Review the preview and choose a destination." + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + previewFailed = true + status = "Transfer preview unavailable. Check the source connection, then retry." + } finally { + previewLoading = false } - .onFailure { status = "Transfer preview unavailable. Reconnect and retry." } } Column( Modifier.verticalScroll(rememberScrollState()).intentPageInsets(), @@ -112,11 +151,24 @@ fun WorkspaceTransferScreen( color = IntentScreen.secondaryText ) if (busy || finished) { - TransferStatus(progress, control::cancel, onDone) + Text(workspaceTitle, style = MaterialTheme.typography.titleMedium) + Text("From $sourceName", color = IntentScreen.secondaryText) + selectedDestination?.let { + Text("To ${it.displayName}", color = IntentScreen.secondaryText) + } + TransferStatus(progress, control::cancel, onDone) { + finished = false + attempt++ + } return@Column } IntentSection("Transfer preview") { Text(status) + if (previewFailed) { + Button(onClick = { retryPreview++ }, enabled = sourceState == SessionState.READY) { + Text("Retry preview") + } + } plan?.let { preview -> val bytes = (preview["totalSizeBytes"] as? JsonPrimitive)?.longOrNull val size = @@ -133,6 +185,8 @@ fun WorkspaceTransferScreen( } } IntentSection("Source workspace") { + Text(workspaceTitle, style = MaterialTheme.typography.bodyLarge) + Text(sourceName, color = IntentScreen.secondaryText) Row(verticalAlignment = Alignment.CenterVertically) { Checkbox(archive, { archive = it }) Text("Archive the source after confirmed import") @@ -143,34 +197,58 @@ fun WorkspaceTransferScreen( Text("Pair another host in Settings first.", color = IntentScreen.secondaryText) } destinations.forEach { destination -> - Button(onClick = { - busy = true - onBusy(true) - status = "Transferring workspace…" - scope.launch { - val result = withContext(Dispatchers.IO + NonCancellable) { - runCatching { - transferTo( - operations, - workspaceId, - destination, - archive, - control, - createTarget + FilterChip( + selected = destination.id == destinationId, + onClick = { destinationId = destination.id }, + label = { Text(destination.displayName) } + ) + } + Text( + selectedDestination?.let { "Selected destination: ${it.displayName}" } + ?: "Choose a destination before starting the transfer.", + color = IntentScreen.secondaryText + ) + } + val sourceEligible = owner && sourceState == SessionState.READY + val previewReady = !previewLoading && plan != null + Button( + onClick = { + val destination = selectedDestination + if (busy || destination == null || !sourceEligible) { + return@Button + } + busy = true + onBusy(true) + scope.launch { + val result = withContext(Dispatchers.IO + NonCancellable) { + runCatching { + check( + transferPrincipalIsOwner( + operations.request("principal.me"), + source.serverCapabilities ) + ) { + "Owner access is required on the source host." } + transferTo( + operations, + workspaceId, + destination, + archive, + control, + createTarget + ) } - result.onFailure { - connectionFailed(control) - } - finished = true - busy = false - onBusy(false) } - }, enabled = plan != null) { - Text("Transfer to ${destination.displayName}") + result.onFailure { connectionFailed(control) } + finished = true + busy = false + onBusy(false) } - } + }, + enabled = previewReady && selectedDestination != null && sourceEligible + ) { + Text("Transfer workspace") } } } @@ -179,7 +257,7 @@ private fun connectionFailed(control: TransferControl) { if (control.state.value.phase.busy) { control.finish( TransferPhase.FAILED, - "Cannot connect to the destination. The source was not changed." + "Could not confirm the connection and owner access on both hosts. The source was not changed." ) } } @@ -202,6 +280,14 @@ private fun transferTo( try { awaitDestination(ready, control) control.checkCancellation() + check( + transferPrincipalIsOwner( + RpcWorkspaceOperations(target).request("principal.me"), + target.serverCapabilities + ) + ) { + "Owner access is required on the destination host." + } WorkspaceTransfer( source, RpcWorkspaceOperations(target), @@ -234,7 +320,12 @@ private fun awaitDestination(ready: CompletableFuture, control: TransferCo } @Composable -private fun TransferStatus(progress: TransferProgress, cancel: () -> Unit, onDone: () -> Unit) { +private fun TransferStatus( + progress: TransferProgress, + cancel: () -> Unit, + onDone: () -> Unit, + onRetry: () -> Unit +) { IntentSection( when (progress.phase) { TransferPhase.CANCELLED -> "Transfer cancelled" @@ -267,6 +358,9 @@ private fun TransferStatus(progress: TransferProgress, cancel: () -> Unit, onDon Text("The transfer can no longer be cancelled. Keep both hosts connected.") } } else { + if (progress.phase == TransferPhase.FAILED) { + Button(onClick = onRetry) { Text("Review and retry") } + } Button(onClick = onDone) { Text("Done") } } } diff --git a/app/src/main/res/drawable/intent_close.xml b/app/src/main/res/drawable/intent_close.xml new file mode 100644 index 0000000..66e3f41 --- /dev/null +++ b/app/src/main/res/drawable/intent_close.xml @@ -0,0 +1,8 @@ + + + + diff --git a/app/src/main/res/drawable/intent_dandelion.xml b/app/src/main/res/drawable/intent_dandelion.xml new file mode 100644 index 0000000..07d6e3e --- /dev/null +++ b/app/src/main/res/drawable/intent_dandelion.xml @@ -0,0 +1,7 @@ + + + diff --git a/app/src/main/res/layout/pairing_scanner.xml b/app/src/main/res/layout/pairing_scanner.xml index 81b2430..ea6d66b 100644 --- a/app/src/main/res/layout/pairing_scanner.xml +++ b/app/src/main/res/layout/pairing_scanner.xml @@ -8,8 +8,9 @@