Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ data class Conversation(
val isPromoted: Boolean,
val lastUsedAt: Instant,
val isSleeping: Boolean = false,
val archived: Boolean = false,
)

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ interface ConversationRepository {
): Session
}

enum class ConversationFilter { All, Channels, Discussions }
enum class ConversationFilter { All, Channels, Discussions, Archived }

/**
* One row in the conversation thread. The stream interleaves messages
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,9 @@ class FakeConversationRepository(
}.filter { conv ->
when (filter) {
ConversationFilter.All -> true
ConversationFilter.Channels -> conv.isPromoted
ConversationFilter.Discussions -> !conv.isPromoted
ConversationFilter.Channels -> conv.isPromoted && !conv.archived
ConversationFilter.Discussions -> !conv.isPromoted && !conv.archived
ConversationFilter.Archived -> conv.archived
}
}.sortedByDescending { it.lastUsedAt }
}
Expand Down Expand Up @@ -123,8 +124,11 @@ class FakeConversationRepository(

override suspend fun archive(conversationId: String) {
state.update { records ->
if (conversationId !in records) throw unknown(conversationId)
records - conversationId
val record = records[conversationId] ?: throw unknown(conversationId)
records + (
conversationId to
record.copy(conversation = record.conversation.copy(archived = true))
)
}
}

Expand Down Expand Up @@ -319,6 +323,14 @@ class FakeConversationRepository(
claudeSessionUuid = "seed-claude-discussion-a",
lastUsedAt = Instant.parse("2026-05-11T14:00:00Z"),
),
seedDiscussion(
id = "seed-discussion-archived",
cwd = DEFAULT_SCRATCH_CWD,
sessionId = "seed-session-discussion-archived",
claudeSessionUuid = "seed-claude-discussion-archived",
lastUsedAt = Instant.parse("2026-04-15T12:00:00Z"),
archived = true,
),
)
return seeds.associateBy { it.conversation.id }
}
Expand Down Expand Up @@ -422,6 +434,7 @@ class FakeConversationRepository(
sessionId: String,
claudeSessionUuid: String,
lastUsedAt: Instant,
archived: Boolean = false,
): ConversationRecord {
val session =
Session(
Expand All @@ -440,6 +453,7 @@ class FakeConversationRepository(
sessionHistory = listOf(sessionId),
isPromoted = false,
lastUsedAt = lastUsedAt,
archived = archived,
)
return ConversationRecord(conversation, mapOf(sessionId to session))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@ class FakeConversationRepositoryTest {
fun observeConversations_emitsExpectedSeeds_initially_for_all_filters() =
runBlocking {
val repo = FakeConversationRepository()
assertEquals(5, repo.observeConversations(ConversationFilter.All).first().size)
assertEquals(6, repo.observeConversations(ConversationFilter.All).first().size)
assertEquals(3, repo.observeConversations(ConversationFilter.Channels).first().size)
assertEquals(2, repo.observeConversations(ConversationFilter.Discussions).first().size)
assertEquals(1, repo.observeConversations(ConversationFilter.Archived).first().size)
}

@Test
Expand Down Expand Up @@ -105,7 +106,7 @@ class FakeConversationRepositoryTest {
fun seededDiscussions_remainEmpty() =
runBlocking {
val repo = FakeConversationRepository()
for (id in listOf("seed-discussion-a", "seed-discussion-b")) {
for (id in listOf("seed-discussion-a", "seed-discussion-b", "seed-discussion-archived")) {
assertEquals(
"discussion $id must be empty",
emptyList<ThreadItem>(),
Expand Down Expand Up @@ -157,7 +158,7 @@ class FakeConversationRepositoryTest {
val repo = FakeConversationRepository()
val created = repo.createDiscussion()
val all = repo.observeConversations(ConversationFilter.All).first()
assertEquals(6, all.size)
assertEquals(7, all.size)
assertTrue(all.any { it.id == created.id })
}

Expand Down Expand Up @@ -205,12 +206,83 @@ class FakeConversationRepositoryTest {
}

@Test
fun archive_removes_from_observeConversations() =
fun archive_movesConversation_from_Discussions_to_Archived_andRetainsInStore() =
runBlocking {
val repo = FakeConversationRepository()
val created = repo.createDiscussion()

assertTrue(
"newly created discussion must appear in Discussions",
repo.observeConversations(ConversationFilter.Discussions).first().any { it.id == created.id },
)
assertTrue(
"newly created discussion must not appear in Archived",
repo.observeConversations(ConversationFilter.Archived).first().none { it.id == created.id },
)

repo.archive(created.id)

assertTrue(
"archived conversation must leave Discussions",
repo.observeConversations(ConversationFilter.Discussions).first().none { it.id == created.id },
)
assertTrue(
"archived conversation must not appear in Channels",
repo.observeConversations(ConversationFilter.Channels).first().none { it.id == created.id },
)
assertTrue(
"archived conversation must appear in Archived",
repo.observeConversations(ConversationFilter.Archived).first().any { it.id == created.id },
)
assertTrue(
"archived conversation must be retained in All",
repo.observeConversations(ConversationFilter.All).first().any { it.id == created.id },
)
}

@Test
fun seededArchivedDiscussion_appearsIn_Archived_butNotIn_Discussions() =
runBlocking {
val repo = FakeConversationRepository()
val archivedId = "seed-discussion-archived"
assertTrue(
"seeded archived discussion must appear in Archived",
repo.observeConversations(ConversationFilter.Archived).first().any { it.id == archivedId },
)
assertTrue(
"seeded archived discussion must not appear in Discussions",
repo.observeConversations(ConversationFilter.Discussions).first().none { it.id == archivedId },
)
assertTrue(
"seeded archived discussion must be retained in All",
repo.observeConversations(ConversationFilter.All).first().any { it.id == archivedId },
)
}

@Test
fun archive_onUnknownId_throws() {
val repo = FakeConversationRepository()
try {
runBlocking { repo.archive("nope") }
assertTrue("expected IllegalArgumentException", false)
} catch (_: IllegalArgumentException) {
// expected
}
}

@Test
fun archive_isIdempotent() =
runBlocking {
val repo = FakeConversationRepository()
val created = repo.createDiscussion()
repo.archive(created.id)
repo.archive(created.id)
assertTrue(repo.observeConversations(ConversationFilter.All).first().none { it.id == created.id })
val archived = repo.observeConversations(ConversationFilter.Archived).first()
assertEquals(
"archived conversation must appear exactly once after two archive() calls",
1,
archived.count { it.id == created.id },
)
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,7 @@ class ChannelListViewModelTest {
ConversationFilter.Channels -> channels
ConversationFilter.Discussions -> discussions
ConversationFilter.All -> TODO("not used")
ConversationFilter.Archived -> TODO("not used")
}

override fun observeMessages(conversationId: String): Flow<List<ThreadItem>> = TODO("not used")
Expand Down
4 changes: 2 additions & 2 deletions docs/knowledge/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ One-line pointers to evergreen docs. Per-ticket implementation notes live under
- [Scanner screen](features/scanner-screen.md) — QR-pairing screen at the `scanner` route; locked to Figma node `13:2` since #60. M3 `TopAppBar("Pair with pyrycode")` over a viewport `Box(weight(1f))` (`surfaceContainerLowest` base + dual radial gradients painted in one `Modifier.drawBehind` + `Canvas` stripe overlay every 7dp + 248dp four-corner `Reticle` with a `Modifier.blur(12.dp, BlurredEdgeTreatment.Unbounded)` halo under a crisp scan line + `HintCard` AnnotatedString with `FontFamily.Monospace` `pyry pair` accent) over a `Trouble scanning?` `TextButton`. AC6 contradiction: visible back-arrow and text-button are both wired to `onTap` — tap anywhere flips `AppPreferences.setPairedServerExists(true)` + navigates to `channel_list` with the scanner popped. Phase 4 replaces with real CameraX + ML Kit. Instrumented Compose tests since #101 — three-method `ScannerScreenTest` at `app/src/androidTest/.../onboarding/ScannerScreenTest.kt` asserts `"Pair with pyrycode"` TopAppBar title (exact), the `buildAnnotatedString` hint card body via `hasText("pyry pair", substring = true)` (substring covers the `AnnotatedString` concatenation without depending on the full sentence), and the `"Trouble scanning?"` fallback `TextButton` carries `hasClickAction()`. Structure only — the `onTap`-fires-from-every-affordance wiring is deliberately unasserted; click-callback coverage is deferred to the Phase 4 rewrite.
- [Scanner Denied screen](features/scanner-denied-screen.md) — stateless camera-permission-denied surface locked to Figma node `32:2` (#61); `Surface` + `Column` with a 120dp Compose-`Canvas` camera-with-strike illustration (rounded-rect body + viewfinder-bridge `Path` + stroked outer-lens circle + filled inner-lens dot + `colorScheme.error` diagonal `drawLine`), `headlineSmall` headline, `bodyMedium` explainer (`widthIn` 300dp), bottom-pinned `fillMaxWidth` filled `Button` + `TextButton` via a `Spacer(weight=1f)`. Two caller-owned lambdas; no NavHost wiring yet (Phase 4 adds the `TopAppBar` and routes from the CameraX permission flow). Instrumented Compose tests since #101 — three-method `ScannerDeniedScreenTest` at `app/src/androidTest/.../onboarding/ScannerDeniedScreenTest.kt` asserts the `"Camera permission required"` heading (exact match) plus `hasClickAction()` on `"Open settings"` and `"Paste code instead"`; structure only, the two lambdas are passed as `{}` no-ops and callback wiring isn't asserted.
- [Scanner Connecting screen](features/scanner-connecting-screen.md) — stateless in-flight-pairing surface locked to Figma node `32:20` (#62); `Surface` + `Column(Arrangement.Center)` of a 48dp indeterminate `CircularProgressIndicator` (zero theming args — M3 defaults bind `colorScheme.primary` arc + `colorScheme.surfaceVariant` track) → `bodyLarge` headline ("Connecting to your pyrycode server…") on `colorScheme.onSurface` → `bodyMedium` server address on `colorScheme.onSurfaceVariant` with `fontFamily = FontFamily.Monospace` as a top-level `Text` parameter (family-only override of the named role). Single `serverAddress: String` parameter, no callbacks; no NavHost wiring yet (Phase 4 owns the handshake state machine). Instrumented Compose tests since #101 — two-method `ScannerConnectingScreenTest` at `app/src/androidTest/.../onboarding/ScannerConnectingScreenTest.kt` asserts `hasText("Connecting to your pyrycode server", substring = true)` (substring deliberately avoids the U+2026 ellipsis) and exact match on the `"home.lan:7117"` literal passed as `serverAddress`. The project's first instrumented test for a no-callback screen — the pattern degenerates cleanly to two render assertions with no counters or event lists.
- [Data model](features/data-model.md) — `Conversation` / `Session` / `Message` schema in `data/model/`; shared shape across mobile, Discord, and pyrycode CLI consumers. Sentinel `DefaultScratchCwd` co-lives in `Conversation.kt` as the single-source-of-truth marker for unbound-workspace conversations. `Conversation.isSleeping: Boolean = false` (#20) is derived in Phase 1 and wire-parsed in Phase 4.
- [Conversation repository](features/conversation-repository.md) — `ConversationRepository` interface + `ConversationFilter`, `ThreadItem` (`MessageItem` / `SessionBoundary`), `BoundaryReason`; data-layer seam every UI tier binds to. Phase 1 binding is the in-memory `FakeConversationRepository`; the `observeConversations` projection also stamps the derived `Conversation.isSleeping` flag (#20).
- [Data model](features/data-model.md) — `Conversation` / `Session` / `Message` schema in `data/model/`; shared shape across mobile, Discord, and pyrycode CLI consumers. Sentinel `DefaultScratchCwd` co-lives in `Conversation.kt` as the single-source-of-truth marker for unbound-workspace conversations. `Conversation.isSleeping: Boolean = false` (#20) is derived in Phase 1 and wire-parsed in Phase 4. `Conversation.archived: Boolean = false` (#93) is the authoritative archive bit — flipped by `archive(id)`, parsed from the wire in Phase 4.
- [Conversation repository](features/conversation-repository.md) — `ConversationRepository` interface + `ConversationFilter` (`All` / `Channels` / `Discussions` / `Archived` since #93), `ThreadItem` (`MessageItem` / `SessionBoundary`), `BoundaryReason`; data-layer seam every UI tier binds to. Phase 1 binding is the in-memory `FakeConversationRepository`; the `observeConversations` projection also stamps the derived `Conversation.isSleeping` flag (#20). Filter matrix (#93): `All` returns everything including archived; `Channels` / `Discussions` carry an explicit `!archived` clause; `Archived` returns archived rows regardless of `isPromoted`. `archive(id)` since #93 flips the flag instead of removing the entry — the conversation stays observable through `Archived` (and `All`).
- [Dependency injection](features/dependency-injection.md) — Koin scaffold; single `appModule` at `di/AppModule.kt`, `startKoin` owned by `PyryApp.onCreate`. Downstream tickets append `single`/`viewModel` lines.
- [App preferences](features/app-preferences.md) — `AppPreferences` wrapper over a shared `DataStore<Preferences>` (file `app_prefs`); typed `Flow<T>` reads + `suspend` setters for non-secret app state. Keys: `pairedServerExists` (#11/#12/#13), `themeMode: Flow<ThemeMode>` (#86 — `stringPreferencesKey("theme_mode")` holding the enum's `.name`; `entries.firstOrNull { it.name == stored } ?: SYSTEM` fallback for both absent + unparseable; consumed reactively at `MainActivity`'s `setContent` root to resolve `PyrycodeMobileTheme(darkTheme = …)` and inside `composable(Routes.SETTINGS)` to drive the Theme row subtitle).
- [ConversationRow](features/conversation-row.md) — stateless M3 `ListItem`-based row consuming `Conversation` + `Message?`; auto-name fallback branched on `isPromoted`, whitespace-normalized 2-line preview, kotlinx-datetime relative-time bucketing (`just now` / `Nm ago` / `Nh ago` / `Yesterday` / `Nd ago` / abbreviated date). Headline is a `Row` with two natural-width decorations: workspace label (#19, trailing) and sleeping-session dot (#20, leading — 8.dp `Box` filled with `colorScheme.outline`, gated on `Conversation.isSleeping`). Optional `onLongClick: (() -> Unit)? = null` (#25) switches the modifier chain from `Modifier.clickable` to `Modifier.combinedClickable` so callers can attach long-press affordances without fanning a screen-specific concept into the shared primitive. Since #68 the `ListItem` carries a `leadingContent = { ConversationAvatar(conversation) }` slot. Shared between the #46 channel list and the discussions drilldown.
Expand Down
Loading
Loading