# PRD: Web Session Manager

**Module:** `web-sm/` | **Namespace:** `ajet.chat.web.*`
**Status:** v1 | **Last updated:** 2026-02-17

---

## 1. Overview

The Web Session Manager serves the browser-based chat UI. It renders HTML via Hiccup, uses Datastar for SSE-driven reactivity (server pushes HTML fragments to update the page), subscribes to NATS for real-time events, and calls the API for data reads/writes. **No direct PostgreSQL access.**

## 2. Architecture

```
Browser ←─ SSE ──→ Web SM ──→ NATS (subscribe events)
       ←─ HTML ──→        ──→ API (HTTP, data reads/writes)
```

**Key principle:** Full Datastar hypermedia. The server renders all HTML. Client-side JS is minimal — only what Datastar requires plus small helpers for image paste and textarea auto-resize.

## 3. Page Layout (Discord-Style)

```
┌─────┬───────────────┬─────────────────────────────────────────┬──────────────┐
│     │               │  # general                          ☰  │              │
│  C  │  CATEGORIES   │  ┌── channel topic text ──────────┐    │   THREAD     │
│  O  │               │  │                                │    │   PANEL      │
│  M  │  ▼ General    │  │  alice  10:30 AM               │    │  (when open) │
│  M  │    # general  │  │  hello everyone!                │    │              │
│  U  │    # random   │  │                                │    │  Thread in   │
│  N  │               │  │  bob  10:31 AM                  │    │  # general   │
│  I  │  ▼ Dev        │  │  hey! check this out            │    │              │
│  T  │    # backend  │  │  [image preview]                │    │  alice:      │
│  Y  │    # frontend │  │                                │    │  hello!      │
│     │               │  │  ── New Messages ──             │    │              │
│  I  │  ─────────    │  │                                │    │  bob:        │
│  C  │  DIRECT MSGS  │  │  carol  10:45 AM               │    │  reply here  │
│  O  │    alice      │  │  @bob nice work! 👍             │    │              │
│  N  │    bob ●      │  │                                │    │              │
│  S  │    group (3)  │  │  ── Load older messages ──     │    │              │
│     │               │  │                                │    │              │
│  +  │               │  ├────────────────────────────────┤    │              │
│     │  🔍 Search    │  │  @mention autocomplete ↕       │    │              │
│     │               │  │  [message input area      ] 📎│    │              │
│     │               │  │  bob is typing...              │    │              │
│     │               │  └────────────────────────────────┘    │              │
└─────┴───────────────┴─────────────────────────────────────────┴──────────────┘
```

### 3.1 Layout Regions

| Region | ID | Description |
|--------|----|-------------|
| Community Strip | `#community-strip` | Vertical icon strip (far left). Community avatars/initials. Click to switch. `+` button to create/join. |
| Sidebar | `#sidebar` | Channel list with collapsible categories. DM section below separator. Search at bottom. |
| Channel Header | `#channel-header` | Channel name, topic, settings icon. |
| Message List | `#message-list` | Paginated message display. "Load older" button at top (only when more may exist). Shows "Beginning of conversation" otherwise. Cursor-based backwards pagination via `oldestMessageId` signal. |
| Message Input | `#message-input` | Auto-expanding textarea, @mention autocomplete, image paste, typing indicator display. |
| Thread Panel | `#thread-panel` | Slide-in panel on right when viewing a thread. Shows root message + replies. |
| Member List | `#member-list` | Optional right panel showing channel members + presence. Toggle via header icon. |

### 3.2 Community Strip

```
┌─────┐
│ MT  │  ← "My Team" (initials, or community avatar)
├─────┤
│ OS  │  ← "Open Source"
├─────┤
│     │
│ ... │
├─────┤
│ DM  │  ← DMs section (always present, not community-scoped)
├─────┤
│  +  │  ← Create/join community
└─────┘
```

- Active community: highlighted border/background
- Unread indicator: dot badge on community icon
- Mention indicator: red badge with count
- DM icon always at top (or bottom — separate from communities)
- Each community remembers the last-viewed channel

### 3.3 Sidebar — Channel List

```
▼ GENERAL                    ← category (collapsible)
  # general              3   ← unread count badge
  # random
  # announcements

▼ DEVELOPMENT
  # backend              ●   ← mention indicator
  # frontend
  # devops

────────────────────────────  ← separator

DIRECT MESSAGES
  alice                  ●   ← online indicator (green dot)
  bob
  group chat (3)         2   ← unread count
```

- Categories collapsible (click header to toggle)
- Channels: bold name = has unread messages
- Unread count badge (number) for messages
- Red dot for unread @mentions
- DM section: shows online status dot, unread count
- Right-click channel → context menu (mute, leave, mark read)

### 3.4 Message Display

Each message renders as:

```
┌─ avatar ─┬─────────────────────────────────────────────┐
│  [img]   │  alice                        10:30 AM      │
│          │  hello everyone! check out #backend          │
│          │                                              │
│          │  [inline image preview ─────────────]       │
│          │                                              │
│          │  👍 3  ❤️ 1  │  + Add Reaction  │  ⋮ More  │
│          │  💬 2 replies (last reply 5 min ago)         │
└──────────┴──────────────────────────────────────────────┘
```

**Message elements:**
- Avatar (from user profile or OAuth)
- Username (display name, colored if admin/owner)
- Timestamp (relative for today, absolute for older)
- Body (rendered Discord markdown → HTML)
- Mentions: `@alice` highlighted, `@here` highlighted differently
- Channel links: `#backend` clickable
- Inline image preview (click for full size)
- Reaction bar: emoji + count, click to toggle your reaction, `+` to add new
- Thread indicator: reply count + last reply time, click to open thread panel
- Hover actions: reply (thread), react, more (edit, delete, copy link)
- Edited indicator: "(edited)" text after timestamp if `edited_at` is set

**Message grouping:** Consecutive messages from the same user within 5 minutes collapse (no repeated avatar/name).

### 3.5 Message Input Area

```
┌─────────────────────────────────────────────────────────┐
│  @bob ▼                              ← autocomplete    │
│  @bobby                              dropdown            │
│  @bob_builder                                           │
├─────────────────────────────────────────────────────────┤
│                                                         │
│  [auto-expanding textarea                          ] 📎│
│                                                         │
│  alice is typing...                                     │
└─────────────────────────────────────────────────────────┘
```

- Auto-expanding textarea (grows with content, max ~10 lines then scroll)
- Enter sends message and immediately clears input (client-side), Shift+Enter for newline
- `@` triggers user mention autocomplete (search by username/display name)
- `#` triggers channel autocomplete
- `/` at start triggers slash command autocomplete
- `📎` button for image upload (opens file picker, images only)
- Ctrl+V / Cmd+V pastes clipboard images directly
- Typing indicator: "alice is typing..." / "alice and bob are typing..." / "several people are typing..."

### 3.6 Thread Panel

```
┌──────────────────────────────────┐
│  Thread in #general          ✕  │
│─────────────────────────────────│
│  alice  10:30 AM                │
│  hello everyone!                │
│─────────── 3 replies ──────────│
│  bob  10:31 AM                  │
│  hey there!                     │
│                                 │
│  carol  10:32 AM                │
│  hi all!                        │
│                                 │
│  [reply input                ]  │
│  ☐ Also send to #general       │
└──────────────────────────────────┘
```

- Opens as a right panel (does not replace message list)
- Shows root message at top
- Thread replies below
- Reply input at bottom
- Optional "Also send to #general" checkbox (broadcasts reply to channel)
- Close button returns to normal view

### 3.7 Search Modal

```
┌──────────────────────────────────────────┐
│  🔍 Search...                            │
│  ┌────────────────────────────────────┐  │
│  │  [search input                  ]  │  │
│  │  Filter: ○ All ○ Messages          │  │
│  │          ○ Channels ○ Users        │  │
│  └────────────────────────────────────┘  │
│                                          │
│  Results:                                │
│  ┌────────────────────────────────────┐  │
│  │  #backend · alice · Feb 15         │  │
│  │  "check out the new **API** docs"  │  │
│  ├────────────────────────────────────┤  │
│  │  #general · bob · Feb 14           │  │
│  │  "the API is looking good"         │  │
│  └────────────────────────────────────┘  │
│  Load more results                       │
└──────────────────────────────────────────┘
```

- Triggered by Ctrl+K or clicking search in sidebar
- Search input with type filter tabs
- Results show context (channel, author, date) with highlighted matches
- Click result → navigate to message in channel (scroll to it)

## 4. Datastar SSE Integration

### 4.1 How It Works

Datastar uses SSE to push HTML fragment updates from server to client. The server decides what changes and sends targeted DOM updates.

**Connection flow:**
1. Browser loads initial full page (server-rendered Hiccup)
2. Datastar opens SSE connection to `/sse/events`
3. Server subscribes to relevant NATS subjects for the user
4. When events arrive (new message, reaction, typing, etc.), server:
   a. Renders the HTML fragment for the change
   b. Sends it over SSE with a Datastar merge directive
   c. Browser updates the DOM in-place

**SSE endpoint:** `GET /sse/events`
- Params: `?community_id=<uuid>` (which community is active)
- Server holds connection open, pushes fragments

### 4.2 Datastar Fragment Types

| Event | Fragment Target | Behavior |
|-------|----------------|----------|
| New message | `#message-list` | Append message HTML at bottom |
| Message edited | `#msg-{id}` | Replace message content |
| Message deleted | `#msg-{id}` | Remove element |
| Reaction added/removed | `#reactions-{msg-id}` | Replace reaction bar |
| Typing start | `#typing-indicator` | Update typing text |
| Typing stop | `#typing-indicator` | Update typing text |
| User online | `#member-{user-id}` | Update presence dot |
| User offline | `#member-{user-id}` | Update presence dot |
| Channel created | `#channel-list` | Append channel to sidebar |
| Unread count update | `#unread-{channel-id}` | Update badge number |
| Notification | `#notification-badge` | Update notification count |
| Thread reply | `#thread-{parent-id}` | Update reply count / append in thread panel |

### 4.3 SSE Reconnection

If SSE connection drops:
1. Datastar auto-reconnects (built-in)
2. Server uses NATS JetStream to replay missed events since last event ID
3. `Last-Event-ID` header used to resume from correct position

## 5. Client → Server Signals

All user actions are HTTP POSTs (Datastar form submissions):

| Action | Endpoint | Payload |
|--------|----------|---------|
| Send message | `POST /web/messages` | Signals: `messageText`, `activeChannel` |
| Load older messages | `POST /web/messages/older` | Signals: `activeChannel`, `oldestMessageId` (cursor) |
| Edit message | `POST /web/messages/:id/edit` | `{body_md}` |
| Delete message | `POST /web/messages/:id/delete` | — |
| Add reaction | `POST /web/reactions` | `{message_id, emoji}` |
| Remove reaction | `POST /web/reactions/remove` | `{message_id, emoji}` |
| Switch channel | `POST /web/navigate` | Signals: `navChannel` |
| Switch community | `POST /web/navigate` | Signals: `navCommunity` |
| Switch to DMs | `POST /web/navigate` | Signals: `navTarget='dms'` |
| Mark channel read | `POST /web/read` | `{channel_id, message_id}` |
| Upload image | `POST /web/upload` | Multipart (image file) |
| Typing indicator | `POST /web/typing` | `{channel_id}` |
| Search | `POST /web/search` | `{query, type?, community_id?}` |
| Slash command | `POST /web/command` | `{command, channel_id, community_id}` |
| Create community | `POST /web/communities` | `{name, slug}` |
| Create channel | `POST /web/channels` | `{name, type, visibility, category_id?}` |

All of these proxy to the API internally and return Datastar fragment responses.

**Navigation signals:** Navigation uses dedicated signals (`navCommunity`, `navChannel`, `navTarget`) set immediately before `@post` to avoid reading stale `activeCommunity`/`activeChannel` values. The navigate handler returns `datastar-patch-signals` events to sync `activeCommunity`, `activeChannel`, and `oldestMessageId` after navigation.

**Message input:** Enter sends the message and immediately clears the input (client-side `$messageText = ''`). Shift+Enter inserts a newline. The input does not wait for the server response to clear.

**Backwards pagination:** The `oldestMessageId` signal tracks the cursor for loading older messages. On initial load and after each navigation, it's set to the ID of the oldest message in the current batch. Clicking "Load older messages" (or scrolling to the top) POSTs to `/web/messages/older`, which fetches messages before the cursor, prepends them to `#messages-container`, and updates the cursor signal. When no older messages exist, the button is replaced with "Beginning of conversation". The button is only shown when a full page (50 messages) was loaded, indicating there may be more.

## 6. Pages & Routes (Server-Rendered)

| Route | Description |
|-------|-------------|
| `GET /` | Redirect to last community or setup wizard |
| `GET /app` | Main chat application (full page render) |
| `GET /app/channel/:id` | Direct link to a channel |
| `GET /app/dm/:id` | Direct link to a DM |
| `GET /setup` | Community creation wizard (first-time only) |

### 6.1 Setup Wizard Page

```
┌──────────────────────────────────────┐
│                                      │
│     Welcome to ajet chat!            │
│                                      │
│     Create your community            │
│                                      │
│     Name: [My Team              ]    │
│     Slug: [my-team              ]    │
│           chat.example.com/my-team   │
│                                      │
│     [  Create Community  ]           │
│                                      │
└──────────────────────────────────────┘
```

**Note:** The initial first-deployment setup wizard (configure OAuth providers, admin login, create first community) is handled entirely by Auth GW at `/setup/*` routes. The Web SM `/setup` page shown here is used for **subsequent** community creation by already-authenticated users (e.g., clicking `+` on the community strip).

- Slug auto-generated from name (lowercase, hyphenated)
- On submit: creates community + #general, redirects to /app

## 7. Connection Tracking

Web SM maintains per-user connection state:

```clojure
{user-id {:sse-connection   <http-kit-channel>
          :active-community uuid
          :active-channel   uuid
          :nats-subs        [sub-handles...]
          :last-seen        instant}}
```

- On SSE connect: subscribe to user's communities on NATS, track connection
- On SSE disconnect: unsubscribe from NATS, clean up
- On community switch: unsubscribe old community subjects, subscribe new ones
- Multiple tabs: each tab is a separate SSE connection with independent state

## 8. Unread Tracking

**How unread counts are calculated:**

1. `channel_members.last_read_message_id` — stored in API DB
2. When user views a channel: Web SM calls `POST /api/channels/:id/read` with the latest message ID
3. Unread count = messages in channel with `id > last_read_message_id`
4. Mention count = mentions targeting user in those unread messages
5. On SSE events: Web SM pushes updated badge fragments for affected channels

**Sidebar rendering:**
- Bold channel name if unread count > 0
- Number badge for unread message count
- Red dot if unread mentions exist
- Community icon badge: sum of unread mentions across all channels in that community

## 9. Styling & CSS

### 9.1 Approach

**Tailwind CSS** via CDN (play CDN for dev, self-hosted build for prod).

- All layout uses Tailwind utility classes in Hiccup
- Custom CSS limited to: Datastar transition animations, syntax highlighting theme, spoiler reveal animation
- Dark theme only for v1 (Discord-style dark background)
- No CSS preprocessor — Tailwind utilities are sufficient

### 9.2 Color Palette (Dark Theme)

```
Background:        #1e1e2e (base)
Sidebar bg:        #181825 (darker)
Message hover:     #2a2a3c
Text primary:      #cdd6f4
Text secondary:    #a6adc8
Text muted:        #6c7086
Accent:            #89b4fa (links, active channel)
Mention highlight: #f38ba8 (pink, @mention background)
Online dot:        #a6e3a1 (green)
Unread badge:      #f38ba8 (red)
Code bg:           #313244
Input bg:          #313244
Input border:      #45475a
```

### 9.3 Emoji Picker

**Implementation:** Lightweight emoji picker rendered server-side as a Hiccup grid.

```
┌──────────────────────────────────┐
│ 🔍 Search emoji...               │
├──────────────────────────────────┤
│ Frequently Used                  │
│ 👍 ❤️ 😂 🎉 😮 😢 👀 🔥      │
│                                  │
│ Smileys & People                 │
│ 😀 😃 😄 😁 😆 😅 🤣 😂      │
│ 😊 😇 🙂 🙃 😉 😌 😍 🥰      │
│ ...                              │
├──────────────────────────────────┤
│ 😀 👤 🐱 🍎 ⚽ 🚗 💡 🏳️       │  ← category tabs
└──────────────────────────────────┘
```

- Triggered by clicking `+` on reaction bar or emoji button in input
- Server renders emoji grid as Datastar fragment
- Click emoji → POST to add reaction or insert into message input
- Search filters emoji list (server-side filter, returns fragment)
- Category tabs at bottom for quick navigation
- "Frequently Used" based on user's recent emoji (stored in session state)

### 9.4 Notification Toasts

New notifications (beyond badge counts) show as brief toast popups:

```
┌──────────────────────────────────────┐
│  @alice mentioned you in #backend    │  ← toast (top-right)
│  "hey @bob can you review this?"     │
│                                 ✕    │
└──────────────────────────────────────┘
```

- Toasts appear for: @mentions, DMs, thread replies
- Auto-dismiss after 5 seconds
- Click toast → navigate to the message
- Click ✕ → dismiss immediately
- Max 3 toasts visible simultaneously (queue overflow)
- Toasts rendered as Datastar fragments pushed via SSE

## 10. Service Configuration

### 10.1 Config Shape

```clojure
{:server {:host "0.0.0.0" :port 3002}
 :api    {:base-url "http://localhost:3001"}
 :nats   {:url "nats://localhost:4222"
          :stream-name "ajet-events"}
 :assets {:tailwind-cdn true            ;; false in prod (use built CSS)
          :datastar-cdn true}           ;; false in prod (vendored)
 :session {:max-connections 10000}      ;; max concurrent SSE connections
 :ui     {:messages-per-page 50
          :typing-timeout-sec 15
          :toast-duration-sec 5}}
```

### 10.2 Startup / Shutdown Sequence

**Startup:**
```
1. Load config
2. Connect to NATS
3. Initialize connection tracker (atom)
4. Start http-kit server
5. Log "Web SM started on port {port}"
```

**Shutdown (graceful):**
```
1. Stop accepting new SSE connections
2. Send close event to all SSE clients
3. Unsubscribe all NATS subscriptions
4. Close NATS connection
5. Stop http-kit server
6. Log "Web SM stopped"
```

### 10.3 Health Check

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/web/health` | None | Service health |

```json
{"status": "ok", "connections": 142, "checks": {"api": "ok", "nats": "ok"}}
```

### 10.4 Error Pages

| Status | Page | Description |
|--------|------|-------------|
| 404 | Not Found | Unknown web route — shows "Page not found" with link to home |
| 500 | Server Error | Unhandled exception — shows "Something went wrong" with retry link |
| 502 | API Unavailable | API service unreachable — shows banner "Reconnecting..." with auto-retry |

---

## 11. Test Cases

### 11.1 Page Rendering

| ID | Test | Description |
|----|------|-------------|
| WEB-T1 | Initial page load | GET /app returns full HTML with sidebar, message list, input area |
| WEB-T2 | Channel list renders | Sidebar shows categories + channels for active community |
| WEB-T3 | DM list renders | DM section shows user's DM channels |
| WEB-T4 | Community strip renders | Icon strip shows all user's communities |
| WEB-T5 | Message list renders | Channel messages displayed with correct formatting |
| WEB-T6 | Markdown rendering | Discord markdown renders correctly as HTML |
| WEB-T7 | Mention rendering | `@<user:uuid>` renders as `@displayname` with highlight |
| WEB-T8 | Image inline preview | Image attachments render as inline previews |
| WEB-T9 | Thread indicator | Messages with replies show reply count and link |
| WEB-T10 | Setup wizard | GET /setup shows community creation form for first-time users |

### 11.2 SSE & Real-Time

| ID | Test | Description |
|----|------|-------------|
| WEB-T11 | SSE connection established | Browser opens SSE, server subscribes to NATS |
| WEB-T12 | New message appears | Message from another user appears in message list without refresh |
| WEB-T13 | Message edit updates | Edited message content updates in-place |
| WEB-T14 | Message delete removes | Deleted message disappears from list |
| WEB-T15 | Reaction update | Adding/removing reaction updates reaction bar in real-time |
| WEB-T16 | Typing indicator shows | Other user typing shows indicator below input |
| WEB-T17 | Typing indicator clears | Indicator disappears after 15 seconds of no typing |
| WEB-T18 | Presence update | User going online/offline updates sidebar dot |
| WEB-T19 | New channel appears | Channel created by admin appears in sidebar |
| WEB-T20 | Unread badge updates | New message in other channel updates unread count badge |
| WEB-T21 | SSE reconnect | After connection drop, reconnects and catches up on missed events |
| WEB-T22 | Community switch | Switching community updates sidebar and message list |
| WEB-T23 | Channel switch | Switching channel loads new messages, marks old as read |

### 11.3 User Actions

| ID | Test | Description |
|----|------|-------------|
| WEB-T24 | Send message | Typing and pressing Enter sends message, input clears immediately (client-side), message appears in list via SSE |
| WEB-T25 | Shift+Enter newline | Shift+Enter adds newline, does not send |
| WEB-T26 | @mention autocomplete | Typing @ shows user dropdown, selecting inserts mention |
| WEB-T27 | #channel autocomplete | Typing # shows channel dropdown |
| WEB-T28 | /slash command | Typing / shows command autocomplete |
| WEB-T29 | Edit message (hover) | Hover → edit icon → inline edit mode → save |
| WEB-T30 | Edit after 1 hour | Edit button not shown for messages > 1 hour old |
| WEB-T31 | Delete message | Hover → delete icon → confirmation → message removed |
| WEB-T32 | Add reaction | Click + on reaction bar → emoji picker → reaction added |
| WEB-T33 | Toggle reaction | Click existing reaction emoji to toggle on/off |
| WEB-T34 | Image paste | Ctrl+V with clipboard image → upload → preview in input → send |
| WEB-T35 | Image upload button | Click 📎 → file picker → select image → upload → send |
| WEB-T36 | Open thread | Click thread indicator → thread panel opens on right |
| WEB-T37 | Reply in thread | Type in thread input → reply appears in thread |
| WEB-T38 | Search | Ctrl+K → search modal → type query → results shown → click to navigate |
| WEB-T39 | Create channel | Admin clicks + → form → submits → channel appears in sidebar |
| WEB-T40 | Join public channel | Click channel → join prompt → joined → messages load |
| WEB-T41 | Leave channel | Right-click → leave → channel removed from sidebar |
| WEB-T42 | Collapse category | Click category header → channels hidden → click again → shown |
| WEB-T43 | Mark channel read | Opening channel marks it as read, badge clears |
| WEB-T44 | Paginated loading | Click "Load older" or scroll to top → older messages prepended to `#messages-container`, `oldestMessageId` cursor updated. When no older messages, button replaced with "Beginning of conversation". Button hidden when initial load returns < 50 messages. |
| WEB-T45 | Create community | Click + on community strip → wizard → community created |

### 11.4 Profile & Settings

| ID | Test | Description |
|----|------|-------------|
| WEB-T46 | User profile popover | Click username → popover with avatar, name, status, role |
| WEB-T47 | Set status | Click own avatar → status input → save |
| WEB-T48 | Set nickname | In community settings → nickname field → save |

### 11.5 Error Handling

| ID | Test | Description |
|----|------|-------------|
| WEB-T49 | API unreachable | Shows error banner, retries |
| WEB-T50 | Message send fails | Error shown inline below input, message not lost |
| WEB-T51 | Upload too large | Error shown, file not uploaded |
| WEB-T52 | Rate limited | Error shown with retry countdown |

### 11.6 Responsive / Layout

| ID | Test | Description |
|----|------|-------------|
| WEB-T53 | Desktop layout | Full 3-column layout renders correctly |
| WEB-T54 | Thread panel coexists | Thread panel + message list visible simultaneously |
| WEB-T55 | Long messages wrap | Long messages wrap correctly, no horizontal scroll |
| WEB-T56 | Code block rendering | Fenced code blocks render with syntax highlighting |
| WEB-T57 | Spoiler tags | `||spoiler||` renders hidden, click to reveal |
