The transcript carries a proposal past as it happens, andb977326gave a member owed two decisions a queue to find the second one in. Neither says anything before it is tapped. A proposal announces itself as one line among everything else the room said, the conversation carries it upward, and from then on the only evidence that the group is waiting on this member is a line they would have to scroll back to -- or the Proposals button on a screen behind a kebab menu, which is a place to look rather than a thing that tells you to look. The decision does not expire with the scroll, and a member who has not answered is what the whole room is waiting on. **Where it sits.** First item of the transcript's LazyColumn, which has `reverseLayout = true`, so index 0 is at the bottom edge of the viewport -- under the newest message and directly above the composer. That is also where the list is scrolled to when a room is opened, so a member who has just been asked for something is told so without moving. `ChatMessageDao` orders `createdAt DESC` and the reversal turns it back the right way up, which is why "first item" and "under the newest message" are the same place. **Not pinned above the composer.** It scrolls with the transcript and leaves view when a reader goes back through history. Pinning it is not a move of the composable: `RenderMessages` is a Column of `Spacer(weight(1f))` then the messages column, and a Column measures its unweighted children first and in order, each against what the previous ones left. The messages column holds a LazyColumn with no height modifier, which takes the whole remaining height, so a sibling card placed after it would be measured against nothing and would not appear. Making that work wants `weight(1f)` moved onto the messages column, which changes how every part of this screen is measured rather than where one card goes. **Guarded outside `item { }` rather than inside it.** The relay-list notice beside it is written the other way round -- an item that always exists and sometimes composes nothing -- and `verticalArrangement = Arrangement.spacedBy` puts its gap between every pair of adjacent items whatever their height, so an item composing nothing still costs 10.dp. This one is not added at all when nothing is owed, so a room with no proposals carries no phantom gap at the foot of its transcript. **Read before the list builder.** `proposalsAwaitingYou` is pulled into a local above the `LazyColumn` call rather than inside its scope, so the state read is plainly a read of `RenderMessages` and the notice appearing or disappearing is a recomposition of this function. Reading it inside the builder would work -- the item provider is snapshot-aware -- but it puts the difference between "no proposals" and "one proposal" inside a lambda whose re-execution is the lazy list's business rather than this function's. **What the held state carries now.** `proposalsAwaitingYou` widens from `Set<String>` to `List<AwaitingProposal>`: the session id it already had, plus a `ProposedEvent.Summary` of what the batch is about and the batch's size. It is still filled from the same `observeSessionsForChatRoom` collector asking `FrostSigningManager.isAwaitingApproval` -- the point of b977326's version was that the transcript and the proposal list ask one question, and that is unchanged. The summary is built in the collector rather than at render because it is parsed out of stored JSON, once per item, and this is a scrolling list. `ProposalListUIState.Proposal` derives its own for the same reason and says so; doing it in the composable would re-parse every event in every open proposal on every recomposition of the room. **The first item that can be read, not the first item.** `firstNotNullOfOrNull` over `Event.fromJsonOrNull`, matching `ProposalListScreen`, whose `lead` is the first of the already-`mapNotNull`ed events. A batch is named after its first item because the rest hang off it, but a batch whose first item this build cannot parse is still about something, and falling back to "Proposal" when the second item says "New chapter" would be throwing away the name for a reason the reader cannot see. **Named when there is one, counted when there are several.** One proposal gets "Waiting for your signature" over what it signs -- "New chapter · Genesis 1 · 797 words · 31 chunks" -- because "a proposal is waiting" is not something anybody can decide about, and the whole value of the card over a dot on a menu is that it says what the group wants. Several get the count and nothing else. Naming the first of several would say the others were not there, which is exactly the fault `ProposalListScreen` was built to fix; listing them all would be building that screen a second time in the composer's space. **The two ways to have nothing to name.** A session can exist before its proposal has arrived, and a proposal can arrive holding events this build cannot read. They are different situations and the card says which, in the same words `ProposalCard` uses -- "Nothing has arrived to sign yet" against "None of its events could be read" -- so a member who taps through finds the row saying what the card said. **It opens the queue, in every case.** Not the proposal it names, even when it names exactly one. The transcript's own lines are the way to a single proposal and keep their existing routing; this is the standing count of what is owed, and the queue is the screen that answers the question it raises -- including for a proposal it could not name, where opening one session would be opening the one thing the card just admitted it could not describe. **Its own callback rather than `onOpenSigning(null)`.** That would have worked: `ChatRoomMessagingScreen` already sends a null session id to `ProposalListRoute`. But null there is a claim -- "this line predates `ChatMessage.frostSigningSessionId` and cannot say which proposal it meant" -- and the card knows precisely which proposals it is about. Reusing the branch would make the null case mean two unrelated things and leave the next reader unable to tell which callers actually do not know their session. `onOpenProposals: () -> Unit` says the one thing it does. **`hidesOtherDecisions` is untouched in meaning.** It follows the new shape -- `size > 1 && any { it.sessionId == sessionId }`, with the cheap check first -- and still answers the same two questions about a tapped transcript line. No line changes where it goes. **Not covered, deliberately.** The empty-transcript branch gets no card. A proposal writes its own lines into the room that signs, which is whatb977326established, so an awaiting proposal implies a transcript; the branch this skips is the "Break the ice" case, which cannot coexist with one. The card lives and dies with an open room.b977326closed by noting that nothing counts outstanding decisions where a reader can see them before tapping, and that is still true one level up: the home chat list says nothing, and a badge there wants this count somewhere it outlives one open room, which is its own change. `ChatRoomMessagingScreen` still calls `initiate()` inside `key(true) { }` rather than a `LaunchedEffect`, so its collectors are re-launched on recomposition. This adds no observer -- it reads the oneb977326added -- so the exposure is unchanged rather than widened. No tests. What changed is a composable and a navigation callback, and there is no UI test harness here to press a card in. The seam that does have one, `isAwaitingApproval`, is untouched and is already what the proposal list is tested through; the summary this reuses, `ProposedEvent.summarize`, is likewise already covered where it is defined. Verified: :composeApp:compileDebugKotlinAndroid succeeds; 884 tests pass, 565 jvm and 319 android, unchanged from before the change since it adds none. That the card appears exactly when a proposal is owed, and that it lands on the queue, are read from the code rather than asserted -- both want the app on a device in a group that has a key. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This is a Kotlin Multiplatform project targeting Android, iOS, Desktop (JVM).
-
/composeApp is for code that will be shared across your Compose Multiplatform applications. It contains several subfolders:
- commonMain is for code that’s common for all targets.
- Other folders are for Kotlin code that will be compiled for only the platform indicated in the folder name. For example, if you want to use Apple’s CoreCrypto for the iOS part of your Kotlin app, the iosMain folder would be the right place for such calls. Similarly, if you want to edit the Desktop (JVM) specific part, the jvmMain folder is the appropriate location.
-
/iosApp contains iOS applications. Even if you’re sharing your UI with Compose Multiplatform, you need this entry point for your iOS app. This is also where you should add SwiftUI code for your project.
Build and Run Android Application
To build and run the development version of the Android app, use the run configuration from the run widget in your IDE’s toolbar or build it directly from the terminal:
- on macOS/Linux
./gradlew :composeApp:assembleDebug - on Windows
.\gradlew.bat :composeApp:assembleDebug
Build and Run Desktop (JVM) Application
To build and run the development version of the desktop app, use the run configuration from the run widget in your IDE’s toolbar or run it directly from the terminal:
- on macOS/Linux
./gradlew :composeApp:run - on Windows
.\gradlew.bat :composeApp:run
Build and Run iOS Application
To build and run the development version of the iOS app, use the run configuration from the run widget in your IDE’s toolbar or open the /iosApp directory in Xcode and run it from there.
Learn more about Kotlin Multiplatform…