400 lines
20 KiB
Markdown
400 lines
20 KiB
Markdown
# 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.
|