A full-screen chat thread between Quinn and the client. Quinn's messages appear on the right (gold-tinted bubbles), client messages on the left. Newest messages scroll into view automatically.
`useMessageStream` (`src/hooks/useMessageStream.ts`) opens a Server-Sent Events connection to `GET /vip/messages/:token/stream`. Incoming events are merged into local state, deduped by `msg.id`.
When `Notification.permission === 'default'`, a soft prompt appears above the composer. On approval, `registerPushSubscription(token)` sends the browser's `PushSubscription` to `PUT /vip/push/:token/push-subscription`. The server stores it and sends a Web Push on new inbound Quinn messages.
Push state machine: `idle → requesting → granted | denied`. Once `granted` or `denied`, the prompt is hidden.
---
## Error states
| Condition | Behavior |
|-----------|---------|
| `invalid_token` on fetch | Sets `invalidToken = true` → portal renders "link no longer valid" |
| `invalid_token` on send | Same |
| Network error on fetch | Error card with "Try again" button |
| Network error on send | `error` state shown inline |
---
## Non-obvious details
-`bottomRef` div at the end of the message list is scrolled into view whenever messages change **and** the messages tab is active — prevents unwanted scroll when on other tabs.
- Messages tab is the only tab with a `<form>` composer and a push prompt — other tabs just render their content in the `flex: 1` area between header and nav.