# TODO — v0.23.2 Working checklist. Covers remaining v0.23.1 multi-user scope plus deferred v0.22.8 surface integration and the channel persistence bug. ROADMAP and CHANGELOG updated when work is complete. --- ## Already Shipped (in 0.23.0 / 0.23.1 changesets) Backend foundation is in place. These are done and should not be re-implemented. - [x] Migration 016: `ai_mode`, `topic` on channels; `user_presence` table; channel type constraint extended to `dm`, `channel` - [x] `ai_mode` guard in `completion.go` — `off` returns 403, `mention_only` without @mention returns `{status: delivered}` - [x] `resolveMention()` resolves users (steps 3–4: exact + prefix on `users.username`, self-mention blocked) - [x] User @mention in completion handler — skips AI, attempts `NotifyUserMention` via interface cast (hub method not yet implemented — silently no-ops) - [x] Presence endpoints: `POST /presence/heartbeat` (upsert), `GET /presence?users=...` (90s threshold query) - [x] Folders: full CRUD handler (`handlers/folders.go`), wired routes - [x] `CreateChannel` accepts `type` field (default `direct`) - [x] `ListChannels` supports `?types=dm,channel` multi-filter - [x] Three-section sidebar in `chat.html` (Projects / Channels / Chats) - [x] `renderChannelsSection()` in `ui-core.js` (# / person icons, unread badges, online dots, active state) - [x] `selectChannel()`, `newChannelOrDM()`, `loadChannels()` in `projects-ui.js` - [x] Folder UI: create, rename, delete, context menu, drag chats - [x] Chat list filters out `type=channel` and `type=dm` - [x] API methods: `createChannel(title,model,sp,type)`, `listSidebarChannels()`, `presenceHeartbeat()` - [x] `persona_groups` + `persona_group_members` tables (migration 004) - [x] `PersonaGroup` / `PersonaGroupMember` model structs - [x] Channel participants: CRUD handler, auto-created on channel create --- ## Bug Fixes - [x] **Channel persistence through refresh.** `loadChannels()` read `resp.channels` but `ListChannels` returns a `paginatedResponse` with `data` key. Channels created in-session appeared (pushed to `App.channels` client-side) but vanished on reload because the API response was always parsed as empty. Fix: `resp.channels` → `resp.data` in `projects-ui.js`. - [x] **Folder drag-and-drop completely broken.** Three compounding issues: (a) `loadChats()` never mapped `c.folder` → `folderId` — all chats rendered as unfiled regardless of DB state. (b) Folder groups rendered "Drop chats here" but had zero drag event handlers (`ondragover`/`ondrop` missing). (c) No drop zone existed for unfiling a chat or removing from a project — the Chats section body had no drag handlers. Fixes: added `folderId: c.folder || null` to `loadChats()` map; added `ondragover`/`ondragleave`/`ondrop` to `.sb-folder-group` divs in `renderChatList()`; added `onChatSectionDrop` handler to `#sbBodyChats` (handles both unfile-from-folder and remove-from- project); added `onFolderDrop` and `moveChatToFolder` functions; added "Move to folder" / "Remove from folder" to chat context menu; added drag-over CSS for folders and section body. - [x] **DM creation 404 on `/api/v1/users`.** Route didn't exist. Added `GET /api/v1/users/search?q=` endpoint returning id, username, display_name for approved users (max 20, excludes caller). Registered on `protected` group (any authed user). Rewrote DM creation UI from text input to lazy search modal — shows all users on open, filters as you type with 200ms debounce, click to select and create DM. - [x] **No unfiled drop zone when folders exist.** When folders were present, folder groups consumed all space in `#sbBodyChats`, leaving no bare target to drag a chat out of a folder. Added `.sb-unfiled-zone` wrapper around unfiled chats with its own drag handlers. Shows "Drop here to unfile" hint when zone is empty. `onUnfiledDrop` handles both unfile-from-folder and remove-from-project. - [x] **No channel CRUD.** Channel sidebar items had no context menu or management affordances. Added: right-click context menu with Rename / Set topic (channels only) / Delete. Inline ✕ delete button on hover (same pattern as chat items). `renameChannel()` uses `_showCreationDialog` with pre-filled value. `deleteChannel()` with confirm dialog removes from `App.channels` + `App.chats`, clears active if deleted channel was selected. - [x] **Drag-over highlight persists.** `onChatDragEnd` only cleared `.project-group.drag-over` — missed folders, unfiled zone, and section body. Fixed to `document.querySelectorAll('.drag-over')` so all drop targets are cleaned up when a drag ends anywhere. - [x] **Channel ⋯ menu inconsistency.** Channels used ✕ delete button + right-click only, while folders used ⋯ hover button. Replaced channel ✕ with `.sb-ch-menu` ⋯ button matching the folder pattern (hidden, shown on hover, triggers context menu on click). Both channels and folders now have ⋯ hover + right-click. - [x] **Folder delete silently spills chats.** `deleteFolder()` always unfiled contained chats with no user input. Replaced with a three-button modal when the folder has chats: "Keep chats" (unfile and preserve), "Delete chats too" (hard-delete all contained chats), or Cancel. Empty folders get a simple confirm. Delete-all path iterates chats, calls `API.deleteChannel()` for each, clears active state if needed, then removes the folder. - [x] **Folder ⋯ button shifts collapse arrow.** The ⋯ button used `display:none/block` which caused reflow on hover, shifting the arrow left. Fixed: swapped DOM order (⋯ before arrow), changed to `visibility:hidden/visible` so space is always reserved. Applied same fix to channel ⋯ button. Arrow gets fixed width with `flex-shrink:0`. - [x] **Channels STILL not surviving refresh (round 2).** The unread count subquery in `ListChannels` referenced `cp.last_read_message_id` — a column added by migration 017. If 017 hasn't been applied (fresh DB only has through 016), the query fails and returns zero channels. Additionally, the `$1` parameter was reused in Postgres (subquery + WHERE) which would break SQLite's positional `?` binding. Fix: rewrote unread subquery to use `last_read_at` (exists since migration 005). Split `$1` reuse into `$1` (subquery) and `$2` (WHERE), with `args` providing `userID` twice. `MarkRead` also fixed to use `last_read_at` as primary, `last_read_message_id` as best-effort (silently fails if 017 not applied). - [x] **Settings Models section empty.** Template `models` section fell through to the generic `settingsDynamic` div, but `loadUserModels()` wrote to `#userModelList` which didn't exist. Added dedicated template sections for `models` and `teams`. - [x] **CI: TestIntegration_Messages_CRUD 500.** `ListMessages` JOIN used `u.avatar` but the column is `avatar_url` in the users table. Query failed on SQLite (Postgres would fail too). Same bug in `treepath/path.go` `resolveSenderInfo()`. Fix: `u.avatar` → `u.avatar_url` in both locations. --- ## Design Decisions (resolved) **Unified active conversation.** `App.currentChatId` and `App.currentChannelId` merge into `App.activeConversation = { id, type }` where type is `direct|dm|group|channel`. The send path, model bar, input state, streaming, session restore, and URL sync all key off `activeConversation.id`. The type discriminator drives ai_mode checks, context banner visibility, and participant rendering. Refactor touches `selectChat()`, `selectChannel()`, `sendMessage()`, session restore in `app.js`, and active-highlight in sidebar renderers. **DM deduplication.** One channel per participant pair. `CreateChannel` for `type=dm` checks for an existing DM with the same two participants before creating. Display name is the other party's name (from the caller's perspective). For future 3+ human DMs: comma-join up to 2 names + "and N others". **No LLMs in DMs.** DMs are human-to-human by definition. `ai_mode` is `mention_only` — you can @mention a persona for a one-off response, but personas are not participants. If you want persistent AI in a conversation with another human, that's a Group Chat (`type=group`). The taxonomy stays clean. **Post-creation participant mutation.** Users and personas can be added or removed from any channel/group after creation. Participant CRUD endpoints already exist. Constraints: DMs cannot drop below 2 human participants; groups cannot drop their last persona (becomes a DM at that point — block with error, not silent type coercion). **Channel deletion vs archival.** Governed by `channel_retention.mode`: - `flexible` (default): delete or archive freely, user's choice. - `retain`: archive only. Delete button becomes "Archive" everywhere. `DELETE /channels/:id` returns 403 for non-admins. Admins purge from the admin panel, subject to retention age floor. See "Retention Policy" section below for the full lifecycle. --- ## 1 · Unified Active Conversation Prerequisite refactor. Do this first — everything else builds on it. - [x] **`App.activeConversation` object.** Replace `App.currentChatId` and `App.currentChannelId` with `App.activeConversation = { id, type }` (null when nothing selected). Update `app-state.js`. Added `activeId` getter, `activeType` getter, `setActive(id,type)` method, and `getActiveChat()` helper. - [x] **`selectChat()` migration.** Sets `App.activeConversation = { id: chatId, type: chat.type }`. Session restore reads/writes `cs-active-conversation` (JSON). - [x] **`selectChannel()` migration.** Sets `App.activeConversation = { id, type: ch.type }`. Same session storage key. Now also ensures conversation object in `App.chats` for message caching and maps messages into standard shape. - [x] **Send path.** `sendMessage()` reads `App.activeId` for the channel ID. No behavior change — the backend already handles all channel types on the same completion endpoint. - [x] **Model bar, input state, streaming.** All `currentChatId` references → `App.activeId`. All `App.chats.find(...)` for active chat → `App.getActiveChat()`. 12 files updated. - [x] **Sidebar active highlight.** Both `renderChatList()` and `renderChannelsSection()` check `App.activeId` for the active class. Selecting either clears the other. - [x] **URL sync.** `history.replaceState` uses `/chat/${activeConversation.id}` regardless of type. The Go route handler already loads any channel by ID. - [x] **Session restore.** Reads `cs-active-conversation` JSON, checks both `App.chats` and `App.channels` arrays, calls `selectChat()` or `selectChannel()` as appropriate. - [x] **`newChannelOrDM()` integration.** Now pushes to both `App.channels` (sidebar) and `App.chats` (message cache), and sets the new channel as active. --- ## 2 · Channel + DM Plumbing - [x] **NotifyUserMention on events hub.** Replaced broken gin context interface cast with direct `h.hub.SendToUser()`. Delivers `user.mentioned` event with channel_id, from_user, content preview. Added `user.mentioned` to event route table as `DirToClient`. Added `truncateContent()` helper. - [x] **DM creation flow.** Sidebar "+" shows choice dialog (New Channel / New DM). DM flow uses `_showCreationDialog` with username input, looks up user by username, creates `type=dm` channel with `participants: [targetId]`. Sets `ai_mode=mention_only` by default. - [x] **DM dedup guard.** `CreateChannel` for `type=dm` queries `channel_participants` for existing DM between the two users. Returns existing channel (200) instead of creating duplicate (201). Self-DM blocked with 400. - [x] **Channel context banner.** `#channelContextBanner` div between chat header and messages. `UI.updateContextBanner()` shows/hides based on `App.activeConversation.type`. DM shows partner name + @mention hint. Group shows leader/all routing hint. Channel shows ai_mode + topic. CSS in `chat.css`. - [x] **ai_mode and topic in API response.** Added `AiMode` and `Topic` fields to `channelResponse`. `ListChannels` SELECT and Scan updated. `CreateChannel` INSERTs `ai_mode` (defaults to `mention_only` for DMs, `auto` otherwise). - [x] **DM participant auto-creation.** `CreateChannel` adds all `req.Participants` as member participants alongside the creator (owner). Works for both Postgres and SQLite. - [x] **Unread counts.** Add `last_read_message_id` and `last_read_at` columns to `channel_participants`. Backend computes unread count per channel per user. `listSidebarChannels` response includes `unread_count`. Mark-read on select. --- ## 3 · @mention UX for Users - [x] **Autocomplete includes users.** `channel-models.js` `onInput()` builds candidates from `App.models` only. Add `App.users` list (fetched once on startup from `GET /api/v1/users` or lightweight endpoint). Merge into candidates with a person icon and different accent color. Resolution order in autocomplete: personas first, then users, then models (mirrors backend `resolveMention`). - [x] **User mention pill styling.** `@username` pills render with a different background color (e.g. blue/teal vs persona accent) and person icon to distinguish human from AI mentions. - [x] **Notification on mention.** `user.mentioned` WS event handler in `chat.js` increments unread badge on channel sidebar item and shows a toast with content preview. --- ## 4 · Presence - [x] **Client heartbeat.** 30s `setInterval` calling `API.presenceHeartbeat()`. Start on app init, pause on `visibilitychange` hidden, resume on visible. - [x] **Presence on channel load.** When rendering the Channels section, collect DM partner IDs and call `GET /api/v1/presence?users=...` to populate `App.presence`. Wire into `renderChannelsSection()` online dot. - [x] **Presence WebSocket events.** Hub broadcasts `presence.changed { userId, status }` to sessions in shared channels. Frontend updates `App.presence` and re-renders the relevant sidebar item (not full re-render). --- ## 5 · Persona Groups + Group Chat - [x] **Persona group CRUD endpoints.** Wire `persona_groups` and `persona_group_members` to handlers. Routes: `GET/POST /api/v1/persona-groups`, `GET/PUT/DELETE /api/v1/persona-groups/:id`, `POST/DELETE /api/v1/persona-groups/:id/members`. `is_leader` flag on members. - [x] **"New Group Chat" flow.** UI in sidebar "+" or separate button. Option A: pick from saved persona group template. Option B: ad-hoc persona picker (checkboxes). Creates `type=group` channel, adds persona participants, sets leader via `is_leader`. Humans and personas can be added post-creation via participant CRUD. - [x] **Group leader default response.** In `completion.go`, when channel `type=group` and no @mention in content, resolve the leader persona from `persona_group_members WHERE is_leader` (via `channel_participants` → group membership). Use leader's model/config/system prompt for the completion. - [x] **`@all` fan-out.** When `@all` is detected in message content, route to every persona participant. Reuse existing `multiModelStream` path or sequential chain. Depth-1 only (responses from @all do not trigger further chains). - [x] **Participant mutation guards.** DMs: block removal if it would drop below 2 human participants. Groups: block removal of last persona (return 400 with clear error message, not silent type coercion). --- ## 6 · Message Attribution - [x] **Human sender display.** Messages from other human participants show their avatar + display name instead of "You". Requires the message response to include `sender_name`, `sender_avatar` (or `participant_type=user` + `participant_id` that the frontend resolves from a user cache). - [x] **Persona sender display.** Already works for streaming via avatar resolution. Verify it works for loaded history in channel context (not just direct chats). --- ## 7 · Channel Lifecycle - [x] **Archive action.** Context menu on channel/DM sidebar items: "Archive". Sets `is_archived = true, archived_at = NOW()` via `PUT /channels/:id`. Archived channels move to a collapsed "Archived" sub-section at the bottom of the Channels list (or hidden entirely with a "Show archived" toggle). - [x] **Delete action (gated).** When `channel_retention.mode` is `flexible` (default): context menu shows "Delete". Hard-deletes the channel and all messages. When `retain`: context menu shows "Archive" only. No delete option for non-admins. - [x] **Admin purge.** Admin panel gets a "Channels" section or row in an existing section showing archived channels with age. "Purge" button hard-deletes. When `purge_after_days` is set, purge is blocked for channels archived less than N days ago. - [x] **`channel_retention` config keys.** Add to `global_config`: `channel_retention.mode` (`flexible`|`retain`, default `flexible`), `channel_retention.purge_after_days` (int|null, default null), `channel_retention.archive_after_days` (int|null, default null). Admin settings UI: "Retention" section under System. --- ## 8 · Surface Integration (deferred v0.22.8) These were on the v0.22.8 checklist and haven't been wired yet. - [x] **ChatPane.primary bridge.** `ChatPane.create()` in `startApp()` from server-rendered mount points. `UI.renderMessages` delegates to `ChatPane.primary.renderMessages`. Currently `ChatPane.primary` is defined but never assigned. - [x] **Theme save/load cycle.** Wire `Theme` (from `ui-primitives-additions.js`) into settings appearance save/load. Persist preference to user settings API. CM6 theme sync on change. - [x] **Admin hybrid section loaders.** Complete all JS-loaded admin sections (some still show empty panels). - [x] **Settings surface completeness.** Models visibility toggles, User Personas CRUD, BYOK provider CRUD, Teams tab content. --- ## Forward: Retention Policy Automation (post-0.23.2) Not in scope for 0.23.2 but documents the planned direction. **Lifecycle:** Active → Archived → Purged. Each transition is either user-initiated or policy-driven. **Auto-archive scanner.** Background job (same pattern as compaction scanner). When `channel_retention.archive_after_days` is set, queries `channels WHERE is_archived = false AND updated_at < NOW() - interval` and sets `is_archived = true`. Runs on a configurable schedule (hourly default). Workflow channels auto-archive when their final stage completes — the scanner catches anything the workflow engine misses. **Auto-purge scanner.** When `channel_retention.purge_after_days` is set, queries `channels WHERE is_archived = true AND archived_at < NOW() - interval` and hard-deletes in batches. This is also the floor for manual admin purge — admins cannot purge channels archived less than N days ago. **Audit trail.** Every archive and purge action (manual or automatic) gets an audit log entry: `action = "channel.archived"` or `action = "channel.purged"` with actor (user ID or "system"), channel metadata snapshot, and timestamp. Slots naturally into v0.24.x alongside RBAC (retention policy is inherently an admin/compliance concern) or as a standalone 0.23.x minor if it lands before auth work starts.