Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
245506c
fix(android): improve pairing and reconnect feedback
wookiehangover Nov 4, 2026
1bf50f6
feat(android): improve file readers and commit details
wookiehangover Nov 4, 2026
7bd88bd
fix(android): improve workspace creation and transfer recovery
wookiehangover Nov 4, 2026
a62d865
fix(android): provide connection readiness to navigation
wookiehangover Nov 4, 2026
fcd6382
fix(android): polish assistant history settings and recent agents
wookiehangover Nov 4, 2026
6e147df
fix(android): connect host identity and settings pairing
wookiehangover Nov 4, 2026
dbbe0ca
fix(conversation): keep follow-up sending available and page questions
wookiehangover Nov 4, 2026
bad7083
feat(browser): add saved previews and workspace script resources
wookiehangover Nov 4, 2026
d2181e3
feat: improve agent groups, watched links and terminal controls
wookiehangover Nov 4, 2026
d7c52ad
feat(workspaces): improve grouping and detail hierarchy
wookiehangover Nov 4, 2026
edcadbc
feat(notes): render inline tasks and linked task metadata
wookiehangover Nov 4, 2026
c358ff5
fix(browser): isolate unsaved sessions and sanitize saved URLs
wookiehangover Nov 4, 2026
22fa61c
refactor(conversation): extract composer send eligibility
wookiehangover Nov 4, 2026
c7d9217
feat(android): integrate UI parity routes and reference baseline
wookiehangover Nov 4, 2026
c691a70
refactor: simplify agent and terminal presentation helpers
wookiehangover Nov 4, 2026
1b83438
refactor: separate agent feedback and terminal key helpers
wookiehangover Nov 4, 2026
f2cdb3d
fix(android): validate parity integration and reconnect recovery
wookiehangover Nov 4, 2026
9490869
fix(notes): hide redundant linked task titles
wookiehangover Nov 4, 2026
6c40fc2
fix(android): refine rendered task and workspace edge cases
wookiehangover Nov 4, 2026
b13f797
fix(connection): stop retrying rejected pairing credentials
wookiehangover Nov 6, 2026
25ac9c1
fix(ui): match workspace edge navigation and Intent launcher
wookiehangover Nov 6, 2026
a28c260
fix(ui): match Intent menu motion and compact workspace rows
wookiehangover Nov 6, 2026
312a1a9
fix(ui): use grey for the open Intent button
wookiehangover Nov 7, 2026
afbea0a
fix(ui): soften workspace list edge below grouping controls
wookiehangover Nov 7, 2026
7fd6951
fix(ui): use Intent close artwork in launcher
wookiehangover Nov 7, 2026
1ad1adf
chore: merge main into Android UI parity branch
wookiehangover Nov 10, 2026
6ff399c
fix(workspaces): hide search on an empty workspace list
spullara Nov 10, 2026
aa5629f
fix(drawers): open workspace drawers by swiping on the page header
spullara Nov 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
192 changes: 192 additions & 0 deletions app/src/androidTest/java/dev/intent/app/notes/NoteTaskUiTest.kt
Original file line number Diff line number Diff line change
@@ -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<String, String>? = 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()
}
}
74 changes: 71 additions & 3 deletions app/src/androidTest/java/dev/intent/app/smoke/AssistantTest.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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")
Expand All @@ -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<JsonElement>().apply {
completeExceptionally(IllegalStateException("Cannot delete conversation"))
}
method == "agent.list" && failRefresh.get() ->
CompletableFuture<JsonElement>().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")
Expand Down Expand Up @@ -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}"""
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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() }
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading