|
| 1 | +# ConversationKit: ConversationView Rewrite PRD & Architecture |
| 2 | + |
| 3 | +## 1. Introduction |
| 4 | +This document outlines the Product Requirements and Technical Architecture for rewriting `ConversationKit`'s `ConversationView`. The objective is to closely mimic the scrolling, layout, and interaction paradigms of the Google Gemini iOS application while strictly preserving the existing public API. |
| 5 | + |
| 6 | +## 2. Product Requirements Document (PRD) |
| 7 | + |
| 8 | +### 2.1 Goals |
| 9 | +* **Gemini-like UX:** Implement a smooth, streaming-friendly chat interface with specific scrolling behaviors ("Sticky Top"). |
| 10 | +* **Distinct Styling:** Provide strong default visual styles differentiating user inputs from AI responses, matching modern conversational UI standards. |
| 11 | +* **Progressive Disclosure:** Expose flexible, SwiftUI-canonical extension points for developers to inject custom views (actions, disclaimers) without cluttering the base API. |
| 12 | +* **Zero API Breakage:** The core `ConversationView` initializers and environment modifiers (like `.onSendMessage`) must remain unchanged. |
| 13 | + |
| 14 | +### 2.2 User Experience (UX) & Interactions |
| 15 | + |
| 16 | +**2.2.1 Visual Layout & Styling** |
| 17 | +* **User Messages:** |
| 18 | + * Right-aligned. |
| 19 | + * Contained within a background bubble. |
| 20 | + * **Shape:** Rounded corners on all sides, with one corner (typically bottom-right) being *slightly less rounded* (a "tail" effect) to indicate the speaker. |
| 21 | +* **AI (Other) Messages:** |
| 22 | + * Left-aligned. |
| 23 | + * No background bubble. |
| 24 | + * Utilizes full markdown rendering natively. |
| 25 | +* **Custom Styling:** If a developer provides a custom `content` ViewBuilder closure to `ConversationView`, the default styling is bypassed entirely. |
| 26 | +* **Loading State:** If an AI message (`participant == .other`) is added to the model with `content == nil` (or empty), the UI must render a loading indicator (e.g., ProgressView/sparkle) at that message's location. |
| 27 | + |
| 28 | +**2.2.2 Scrolling Behavior (The "Push and Pin" Paradigm)** |
| 29 | +* **Send Action:** |
| 30 | + 1. Keyboard immediately dismisses. |
| 31 | + 2. `ScrollView` targets the *newly sent user message* with an `anchor: .top`. However, due to native SwiftUI content-clamping, if the chat is short, the message rests naturally at the bottom above the composer rather than artificially snapping to the absolute top of the screen. |
| 32 | +* **Streaming Content:** As the AI response streams in, it expands downward. The growing content naturally *pushes* the user's anchored message upwards. |
| 33 | +* **The Pin:** The exact moment the total height of the content exceeds the screen's height, SwiftUI's layout engine can finally satisfy the `anchor: .top` constraint. The user's message seamlessly pins to the top navigation bar, and the remaining AI response continues to grow out the bottom out of view. |
| 34 | +* **Manual Override:** Any manual scrolling gesture by the user instantly breaks the top anchor, allowing free, user-directed scrolling. |
| 35 | +* **Subsequent Turns:** The next user message resets the behavior, anchoring the new message with `.top`. |
| 36 | + |
| 37 | +**2.2.3 Auxiliary Views (Progressive Disclosure)** |
| 38 | +* **Message Actions:** AI messages must support an optional action bar beneath the text (e.g., Thumbs Up/Down, Regenerate, Copy). |
| 39 | +* **Disclaimer Text:** A customizable disclaimer must appear **only** beneath the most recent AI message in the thread. |
| 40 | + |
| 41 | +**2.2.4 Message Composer Experience** |
| 42 | +* **Empty State:** The "Send" button is visually disabled if the message input is empty. It relies strictly on native platform disabled styling (e.g., `.borderedProminent` on iOS) to ensure high-contrast readability without artificial opacity stacking. |
| 43 | +* **Generation State (Send/Stop):** When a user sends a message, the `ConversationView` must track the execution of the `onSendMessageAction` closure. While this closure is executing, the composer's "Send" button must transform into a "Stop" button (`stop.fill`). The stop icon utilizes specific, reduced font sizing to maintain visual mass balance against the default send arrow. |
| 44 | +* **Cancellation:** Tapping the Stop button calls `cancel()` on the executing `Task`. This implements a "Zero API Breakage" stop mechanism by relying entirely on standard Swift Cooperative Cancellation. Developers using the SDK must ensure their networking/streaming code checks `Task.isCancelled` to make the stop button effective. |
| 45 | + |
| 46 | +--- |
| 47 | + |
| 48 | +## 3. Architecture & Design Document |
| 49 | + |
| 50 | +### 3.1 Core Architecture Changes |
| 51 | + |
| 52 | +The internal `body` of `ConversationView` will be restructured to support precise programmatic scrolling and state tracking. |
| 53 | + |
| 54 | +**Components:** |
| 55 | +1. **`ScrollView` + `ScrollViewReader`:** Essential for programmatic anchoring. |
| 56 | +2. **Scroll State Tracking:** We must track whether the user is actively dragging. This prevents the view from snapping back to an anchor when the `messages` array updates during an active manual scroll. |
| 57 | + * *Implementation detail:* Depending on minimum iOS version targets, this involves `onScrollPhaseChange` (iOS 18+) or preference keys/geometry readers tracking scroll offset changes. |
| 58 | +3. **`MessageView` (Default Renderer):** Will be updated to handle the `UnevenRoundedRectangle` shape for `.user` and the loading indicator logic for `.other` when `content` is nil. |
| 59 | + |
| 60 | +### 3.2 State & Data Flow (No API Breakage Strategy) |
| 61 | + |
| 62 | +**The "Push and Pin" Anchor Strategy for Scroll Physics:** |
| 63 | +* To achieve perfect conversational AI scrolling physics when a user taps Send, the layout engine must invalidate in the exact same transaction as the keyboard dismissal. |
| 64 | +* The goal is to let the user's message appear near the bottom, be *pushed up* by the AI's generated response, and then permanently *pin to the top* of the screen once the response grows too long. |
| 65 | +* **Resolution:** `ConversationView` maintains a local `@State private var optimisticUserMessage`. When the user taps send, this local state is populated instantly. It tells the `ScrollView` to target this message with `anchor: .top`. |
| 66 | +* **The "Happy Accident":** SwiftUI's layout clamping physically refuses to push short content to the top. So, the message naturally rests at the bottom. As the AI streams text, the total height grows, physically pushing the anchored message upwards until it hits the top edge, at which point SwiftUI finally satisfies the `.top` anchor constraint and locks it in place! |
| 67 | +* The actual developer array catches up asynchronously a millisecond later via `onSendMessageAction`, and `displayedMessages` deduplicates it seamlessly. |
| 68 | + |
| 69 | +**Loading Indicator:** |
| 70 | +* The SDK relies on the developer to immediately append a placeholder `Message` (where `participant == .other` and `content == nil`) before starting the async network request. |
| 71 | +* `ConversationView` simply reacts to this state natively to render the loading dots. |
| 72 | + |
| 73 | +**Send/Stop Button & Task Cancellation:** |
| 74 | +* `ConversationView` maintains a `@State private var sendingTask: Task<Void, Never>?`. |
| 75 | +* When a message is sent, the task is captured: `sendingTask = Task { await onSendMessageAction(...) }`. |
| 76 | +* An Environment value (`isGenerating`) is passed down to `MessageComposerView` based on whether `sendingTask` is non-nil. |
| 77 | +* The Stop button triggers `sendingTask?.cancel()`. Because cancellation is cooperative, the `onSendMessage` block must properly utilize `try Task.checkCancellation()` to halt the stream. |
| 78 | + |
| 79 | +### 3.3 Progressive Disclosure API Design |
| 80 | + |
| 81 | +We will introduce new SwiftUI `EnvironmentValues` and corresponding View Modifiers to enable the required customizability. |
| 82 | + |
| 83 | +**3.3.1 Message Actions** |
| 84 | +```swift |
| 85 | +extension EnvironmentValues { |
| 86 | + @Entry var messageActions: ((any Message) -> AnyView)? |
| 87 | +} |
| 88 | + |
| 89 | +public extension View { |
| 90 | + func messageActions<V: View>(@ViewBuilder actions: @escaping (any Message) -> V) -> some View { |
| 91 | + environment(\.messageActions, { message in AnyView(actions(message)) }) |
| 92 | + } |
| 93 | +} |
| 94 | +``` |
| 95 | +*Usage within ConversationView:* Evaluated inside the loop over `messages`, beneath the message content, specifically when `participant == .other`. |
| 96 | + |
| 97 | +**3.3.2 Disclaimer View** |
| 98 | +```swift |
| 99 | +extension EnvironmentValues { |
| 100 | + @Entry var conversationDisclaimer: AnyView? |
| 101 | +} |
| 102 | + |
| 103 | +public extension View { |
| 104 | + func conversationDisclaimer<V: View>(@ViewBuilder disclaimer: @escaping () -> V) -> some View { |
| 105 | + environment(\.conversationDisclaimer, AnyView(disclaimer())) |
| 106 | + } |
| 107 | +} |
| 108 | +``` |
| 109 | +*Usage within ConversationView:* Rendered at the bottom of the `LazyVStack` only if `messages.last?.participant == .other`. |
| 110 | + |
| 111 | +## 4. Implementation Phasing |
| 112 | +1. **Foundation:** Implement new Environment Keys and Modifiers for Actions and Disclaimer. |
| 113 | +2. **Styling:** Update `MessageView` to implement the `UnevenRoundedRectangle` user bubble and the `nil` content loading state. |
| 114 | +3. **Scroll Architecture:** Refactor `ConversationView` to use `ScrollViewReader` and implement the scroll tracking logic. |
| 115 | +4. **Behavior:** Implement the "Push and Pin" anchor logic triggered upon message submission, respecting manual scroll overrides. |
| 116 | +5. **Integration:** Assemble the Action bars and Disclaimer into the updated scroll view layout. |
0 commit comments