20 KiB
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.
- Migration 016:
ai_mode,topicon channels;user_presencetable; channel type constraint extended todm,channel ai_modeguard incompletion.go—offreturns 403,mention_onlywithout @mention returns{status: delivered}resolveMention()resolves users (steps 3–4: exact + prefix onusers.username, self-mention blocked)- User @mention in completion handler — skips AI, attempts
NotifyUserMentionvia interface cast (hub method not yet implemented — silently no-ops) - Presence endpoints:
POST /presence/heartbeat(upsert),GET /presence?users=...(90s threshold query) - Folders: full CRUD handler (
handlers/folders.go), wired routes CreateChannelacceptstypefield (defaultdirect)ListChannelssupports?types=dm,channelmulti-filter- Three-section sidebar in
chat.html(Projects / Channels / Chats) renderChannelsSection()inui-core.js(# / person icons, unread badges, online dots, active state)selectChannel(),newChannelOrDM(),loadChannels()inprojects-ui.js- Folder UI: create, rename, delete, context menu, drag chats
- Chat list filters out
type=channelandtype=dm - API methods:
createChannel(title,model,sp,type),listSidebarChannels(),presenceHeartbeat() persona_groups+persona_group_memberstables (migration 004)PersonaGroup/PersonaGroupMembermodel structs- Channel participants: CRUD handler, auto-created on channel create
Bug Fixes
-
Channel persistence through refresh.
loadChannels()readresp.channelsbutListChannelsreturns apaginatedResponsewithdatakey. Channels created in-session appeared (pushed toApp.channelsclient-side) but vanished on reload because the API response was always parsed as empty. Fix:resp.channels→resp.datainprojects-ui.js. -
Folder drag-and-drop completely broken. Three compounding issues: (a)
loadChats()never mappedc.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/ondropmissing). (c) No drop zone existed for unfiling a chat or removing from a project — the Chats section body had no drag handlers. Fixes: addedfolderId: c.folder || nulltoloadChats()map; addedondragover/ondragleave/ondropto.sb-folder-groupdivs inrenderChatList(); addedonChatSectionDrophandler to#sbBodyChats(handles both unfile-from-folder and remove-from- project); addedonFolderDropandmoveChatToFolderfunctions; added "Move to folder" / "Remove from folder" to chat context menu; added drag-over CSS for folders and section body. -
DM creation 404 on
/api/v1/users. Route didn't exist. AddedGET /api/v1/users/search?q=endpoint returning id, username, display_name for approved users (max 20, excludes caller). Registered onprotectedgroup (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. -
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-zonewrapper around unfiled chats with its own drag handlers. Shows "Drop here to unfile" hint when zone is empty.onUnfiledDrophandles both unfile-from-folder and remove-from-project. -
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_showCreationDialogwith pre-filled value.deleteChannel()with confirm dialog removes fromApp.channels+App.chats, clears active if deleted channel was selected. -
Drag-over highlight persists.
onChatDragEndonly cleared.project-group.drag-over— missed folders, unfiled zone, and section body. Fixed todocument.querySelectorAll('.drag-over')so all drop targets are cleaned up when a drag ends anywhere. -
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. -
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, callsAPI.deleteChannel()for each, clears active state if needed, then removes the folder. -
Folder ⋯ button shifts collapse arrow. The ⋯ button used
display:none/blockwhich caused reflow on hover, shifting the arrow left. Fixed: swapped DOM order (⋯ before arrow), changed tovisibility:hidden/visibleso space is always reserved. Applied same fix to channel ⋯ button. Arrow gets fixed width withflex-shrink:0. -
Channels STILL not surviving refresh (round 2). The unread count subquery in
ListChannelsreferencedcp.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$1parameter was reused in Postgres (subquery + WHERE) which would break SQLite's positional?binding. Fix: rewrote unread subquery to uselast_read_at(exists since migration 005). Split$1reuse into$1(subquery) and$2(WHERE), withargsprovidinguserIDtwice.MarkReadalso fixed to uselast_read_atas primary,last_read_message_idas best-effort (silently fails if 017 not applied). -
Settings Models section empty. Template
modelssection fell through to the genericsettingsDynamicdiv, butloadUserModels()wrote to#userModelListwhich didn't exist. Added dedicated template sections formodelsandteams. -
CI: TestIntegration_Messages_CRUD 500.
ListMessagesJOIN usedu.avatarbut the column isavatar_urlin the users table. Query failed on SQLite (Postgres would fail too). Same bug intreepath/path.goresolveSenderInfo(). Fix:u.avatar→u.avatar_urlin 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/:idreturns 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.
App.activeConversationobject. ReplaceApp.currentChatIdandApp.currentChannelIdwithApp.activeConversation = { id, type }(null when nothing selected). Updateapp-state.js. AddedactiveIdgetter,activeTypegetter,setActive(id,type)method, andgetActiveChat()helper.selectChat()migration. SetsApp.activeConversation = { id: chatId, type: chat.type }. Session restore reads/writescs-active-conversation(JSON).selectChannel()migration. SetsApp.activeConversation = { id, type: ch.type }. Same session storage key. Now also ensures conversation object inApp.chatsfor message caching and maps messages into standard shape.- Send path.
sendMessage()readsApp.activeIdfor the channel ID. No behavior change — the backend already handles all channel types on the same completion endpoint. - Model bar, input state, streaming. All
currentChatIdreferences →App.activeId. AllApp.chats.find(...)for active chat →App.getActiveChat(). 12 files updated. - Sidebar active highlight. Both
renderChatList()andrenderChannelsSection()checkApp.activeIdfor the active class. Selecting either clears the other. - URL sync.
history.replaceStateuses/chat/${activeConversation.id}regardless of type. The Go route handler already loads any channel by ID. - Session restore. Reads
cs-active-conversationJSON, checks bothApp.chatsandApp.channelsarrays, callsselectChat()orselectChannel()as appropriate. newChannelOrDM()integration. Now pushes to bothApp.channels(sidebar) andApp.chats(message cache), and sets the new channel as active.
2 · Channel + DM Plumbing
- NotifyUserMention on events hub. Replaced broken gin context
interface cast with direct
h.hub.SendToUser(). Deliversuser.mentionedevent with channel_id, from_user, content preview. Addeduser.mentionedto event route table asDirToClient. AddedtruncateContent()helper. - DM creation flow. Sidebar "+" shows choice dialog (New Channel
/ New DM). DM flow uses
_showCreationDialogwith username input, looks up user by username, createstype=dmchannel withparticipants: [targetId]. Setsai_mode=mention_onlyby default. - DM dedup guard.
CreateChannelfortype=dmquerieschannel_participantsfor existing DM between the two users. Returns existing channel (200) instead of creating duplicate (201). Self-DM blocked with 400. - Channel context banner.
#channelContextBannerdiv between chat header and messages.UI.updateContextBanner()shows/hides based onApp.activeConversation.type. DM shows partner name + @mention hint. Group shows leader/all routing hint. Channel shows ai_mode + topic. CSS inchat.css. - ai_mode and topic in API response. Added
AiModeandTopicfields tochannelResponse.ListChannelsSELECT and Scan updated.CreateChannelINSERTsai_mode(defaults tomention_onlyfor DMs,autootherwise). - DM participant auto-creation.
CreateChanneladds allreq.Participantsas member participants alongside the creator (owner). Works for both Postgres and SQLite. - Unread counts. Add
last_read_message_idandlast_read_atcolumns tochannel_participants. Backend computes unread count per channel per user.listSidebarChannelsresponse includesunread_count. Mark-read on select.
3 · @mention UX for Users
- Autocomplete includes users.
channel-models.jsonInput()builds candidates fromApp.modelsonly. AddApp.userslist (fetched once on startup fromGET /api/v1/usersor lightweight endpoint). Merge into candidates with a person icon and different accent color. Resolution order in autocomplete: personas first, then users, then models (mirrors backendresolveMention). - User mention pill styling.
@usernamepills render with a different background color (e.g. blue/teal vs persona accent) and person icon to distinguish human from AI mentions. - Notification on mention.
user.mentionedWS event handler inchat.jsincrements unread badge on channel sidebar item and shows a toast with content preview.
4 · Presence
- Client heartbeat. 30s
setIntervalcallingAPI.presenceHeartbeat(). Start on app init, pause onvisibilitychangehidden, resume on visible. - Presence on channel load. When rendering the Channels section,
collect DM partner IDs and call
GET /api/v1/presence?users=...to populateApp.presence. Wire intorenderChannelsSection()online dot. - Presence WebSocket events. Hub broadcasts
presence.changed { userId, status }to sessions in shared channels. Frontend updatesApp.presenceand re-renders the relevant sidebar item (not full re-render).
5 · Persona Groups + Group Chat
- Persona group CRUD endpoints. Wire
persona_groupsandpersona_group_membersto 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_leaderflag on members. - "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=groupchannel, adds persona participants, sets leader viais_leader. Humans and personas can be added post-creation via participant CRUD. - Group leader default response. In
completion.go, when channeltype=groupand no @mention in content, resolve the leader persona frompersona_group_members WHERE is_leader(viachannel_participants→ group membership). Use leader's model/config/system prompt for the completion. @allfan-out. When@allis detected in message content, route to every persona participant. Reuse existingmultiModelStreampath or sequential chain. Depth-1 only (responses from @all do not trigger further chains).- 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
- 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(orparticipant_type=user+participant_idthat the frontend resolves from a user cache). - 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
- Archive action. Context menu on channel/DM sidebar items:
"Archive". Sets
is_archived = true, archived_at = NOW()viaPUT /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). - Delete action (gated). When
channel_retention.modeisflexible(default): context menu shows "Delete". Hard-deletes the channel and all messages. Whenretain: context menu shows "Archive" only. No delete option for non-admins. - 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_daysis set, purge is blocked for channels archived less than N days ago. channel_retentionconfig keys. Add toglobal_config:channel_retention.mode(flexible|retain, defaultflexible),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.
- ChatPane.primary bridge.
ChatPane.create()instartApp()from server-rendered mount points.UI.renderMessagesdelegates toChatPane.primary.renderMessages. CurrentlyChatPane.primaryis defined but never assigned. - Theme save/load cycle. Wire
Theme(fromui-primitives-additions.js) into settings appearance save/load. Persist preference to user settings API. CM6 theme sync on change. - Admin hybrid section loaders. Complete all JS-loaded admin sections (some still show empty panels).
- 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.