diff --git a/VERSION b/VERSION index 610e287..fda96dc 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.23.1 +0.23.2 diff --git a/docs/TODO-0.23.2.md b/docs/TODO-0.23.2.md new file mode 100644 index 0000000..cbfee6b --- /dev/null +++ b/docs/TODO-0.23.2.md @@ -0,0 +1,399 @@ +# 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. diff --git a/server/database/migrations/017_unread.sql b/server/database/migrations/017_unread.sql new file mode 100644 index 0000000..a3ffb76 --- /dev/null +++ b/server/database/migrations/017_unread.sql @@ -0,0 +1,10 @@ +-- ========================================== +-- Chat Switchboard — 017 Unread Counts +-- ========================================== +-- Adds last_read_message_id to channel_participants for +-- per-user unread count computation. +-- ICD §3 (Channels & Conversations) — v0.23.2 +-- ========================================== + +ALTER TABLE channel_participants + ADD COLUMN IF NOT EXISTS last_read_message_id UUID; diff --git a/server/database/migrations/sqlite/017_unread.sql b/server/database/migrations/sqlite/017_unread.sql new file mode 100644 index 0000000..f122020 --- /dev/null +++ b/server/database/migrations/sqlite/017_unread.sql @@ -0,0 +1,3 @@ +-- Chat Switchboard — 017 Unread Counts (SQLite) + +ALTER TABLE channel_participants ADD COLUMN last_read_message_id TEXT; diff --git a/server/events/types.go b/server/events/types.go index 0deaceb..2c684c6 100644 --- a/server/events/types.go +++ b/server/events/types.go @@ -45,6 +45,9 @@ var routeTable = map[string]Direction{ // User/presence "user.presence": DirToClient, "user.status": DirToClient, + "user.mentioned": DirToClient, // v0.23.2: targeted @mention notification + "typing.user": DirToClient, // v0.23.2: human typing in DM/channel + "message.created": DirToClient, // v0.23.2: chained/DM message delivery // System "system.notify": DirToClient, diff --git a/server/handlers/admin.go b/server/handlers/admin.go index 6786650..8d9feb0 100644 --- a/server/handlers/admin.go +++ b/server/handlers/admin.go @@ -333,6 +333,14 @@ func (h *AdminHandler) PublicSettings(c *gin.Context) { hasAdminPrompt = true } + // Channel retention mode (v0.23.2 — flexible or retain) + retentionMode := "flexible" + if retCfg, err := h.stores.GlobalConfig.Get(c.Request.Context(), "channel_retention"); err == nil { + if mode, ok := retCfg["mode"].(string); ok && mode != "" { + retentionMode = mode + } + } + c.JSON(http.StatusOK, gin.H{ "banner": banner, "branding": branding, @@ -340,9 +348,10 @@ func (h *AdminHandler) PublicSettings(c *gin.Context) { "storage_configured": storageConfigured, "paste_to_file_chars": pasteChars, "policies": gin.H{ - "allow_registration": policies["allow_registration"], - "allow_user_byok": policies["allow_user_byok"], - "allow_user_personas": policies["allow_user_personas"], + "allow_registration": policies["allow_registration"], + "allow_user_byok": policies["allow_user_byok"], + "allow_user_personas": policies["allow_user_personas"], + "channel_retention_mode": retentionMode, }, }) } @@ -724,3 +733,88 @@ func (h *AdminHandler) auditLog(c *gin.Context, action, resourceType, resourceID log.Printf("audit log error: %v", err) } } + +// ── Archived Channels (v0.23.2) ────────────── + +func (h *AdminHandler) ListArchivedChannels(c *gin.Context) { + rows, err := database.DB.QueryContext(c.Request.Context(), database.Q(` + SELECT c.id, c.title, c.type, c.user_id, + COALESCE(u.username, '') AS owner_name, + c.updated_at, + (SELECT COUNT(*) FROM messages WHERE channel_id = c.id) AS message_count + FROM channels c + LEFT JOIN users u ON u.id = c.user_id + WHERE c.is_archived = true + ORDER BY c.updated_at DESC + LIMIT 100 + `)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"}) + return + } + defer rows.Close() + + type archivedChannel struct { + ID string `json:"id"` + Title string `json:"title"` + Type string `json:"type"` + OwnerName string `json:"owner_name"` + UpdatedAt string `json:"updated_at"` + MessageCount int `json:"message_count"` + } + + channels := []archivedChannel{} + for rows.Next() { + var ch archivedChannel + var userID string + if rows.Scan(&ch.ID, &ch.Title, &ch.Type, &userID, &ch.OwnerName, &ch.UpdatedAt, &ch.MessageCount) == nil { + channels = append(channels, ch) + } + } + + // Retention config + retentionMode := "flexible" + if retCfg, err := h.stores.GlobalConfig.Get(c.Request.Context(), "channel_retention"); err == nil { + if mode, ok := retCfg["mode"].(string); ok { + retentionMode = mode + } + } + + c.JSON(http.StatusOK, gin.H{ + "channels": channels, + "retention_mode": retentionMode, + }) +} + +func (h *AdminHandler) PurgeChannel(c *gin.Context) { + channelID := c.Param("id") + + // Verify the channel is actually archived + var isArchived bool + err := database.DB.QueryRowContext(c.Request.Context(), database.Q(` + SELECT is_archived FROM channels WHERE id = $1 + `), channelID).Scan(&isArchived) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"}) + return + } + if !isArchived { + c.JSON(http.StatusConflict, gin.H{"error": "channel must be archived before purging"}) + return + } + + // Hard delete (CASCADE removes messages, participants, models) + _, err = database.DB.ExecContext(c.Request.Context(), database.Q(` + DELETE FROM channels WHERE id = $1 + `), channelID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "purge failed"}) + return + } + + // Audit log + uid := getUserID(c) + h.auditLog(c, uid, "channel.purged", "channel", channelID) + + c.JSON(http.StatusOK, gin.H{"ok": true}) +} diff --git a/server/handlers/channels.go b/server/handlers/channels.go index b210eb5..7d2686b 100644 --- a/server/handlers/channels.go +++ b/server/handlers/channels.go @@ -19,13 +19,16 @@ import ( type createChannelRequest struct { Title string `json:"title" binding:"required,max=500"` - Type string `json:"type,omitempty"` // direct (default), group, workflow + Type string `json:"type,omitempty"` // direct (default), dm, group, channel, workflow Description string `json:"description,omitempty"` Model string `json:"model,omitempty"` SystemPrompt string `json:"system_prompt,omitempty"` ProviderConfigID *string `json:"provider_config_id,omitempty"` Folder string `json:"folder,omitempty"` Tags []string `json:"tags,omitempty"` + // v0.23.2: DM creation + Participants []string `json:"participants,omitempty"` // user IDs for DM (exactly 1 other user) + AiMode string `json:"ai_mode,omitempty"` // auto (default), mention_only, off } type updateChannelRequest struct { @@ -40,6 +43,8 @@ type updateChannelRequest struct { Tags []string `json:"tags,omitempty"` Settings *json.RawMessage `json:"settings,omitempty"` // JSONB merge into existing settings WorkspaceID *string `json:"workspace_id,omitempty"` // bind workspace (v0.21.5) + AiMode *string `json:"ai_mode,omitempty"` // v0.23.2: auto, mention_only, off + Topic *string `json:"topic,omitempty"` // v0.23.2: channel topic } type channelResponse struct { @@ -47,6 +52,8 @@ type channelResponse struct { UserID string `json:"user_id"` Title string `json:"title"` Type string `json:"type"` + AiMode string `json:"ai_mode,omitempty"` + Topic *string `json:"topic,omitempty"` Description *string `json:"description"` Model *string `json:"model"` ProviderConfigID *string `json:"provider_config_id"` @@ -59,6 +66,7 @@ type channelResponse struct { Tags []string `json:"tags"` Settings json.RawMessage `json:"settings,omitempty"` MessageCount int `json:"message_count"` + UnreadCount int `json:"unread_count,omitempty"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } @@ -140,16 +148,24 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) { archived := c.DefaultQuery("archived", "false") folder := c.Query("folder") channelType := c.DefaultQuery("type", "") // empty = all types - channelTypes := c.QueryArray("types") // v0.23.1: multi-value, e.g. ?types=dm&types=channel - // Comma-joined convenience: ?types=dm,channel - if len(channelTypes) == 0 && c.Query("types") != "" { - channelTypes = strings.Split(c.Query("types"), ",") + // v0.23.1: multi-value type filter. Supports both ?types=dm&types=channel + // and comma-joined ?types=dm,channel + var channelTypes []string + if raw := c.Query("types"); raw != "" { + for _, t := range strings.Split(raw, ",") { + t = strings.TrimSpace(t) + if t != "" { + channelTypes = append(channelTypes, t) + } + } } search := strings.TrimSpace(c.Query("search")) projectFilter := c.Query("project_id") // "uuid" or "none" - // Count total - countQuery := `SELECT COUNT(*) FROM channels WHERE user_id = $1 AND is_archived = $2` + // Count total — include channels owned by user OR where user is a participant + countQuery := `SELECT COUNT(*) FROM channels c WHERE (c.user_id = $1 OR c.id IN ( + SELECT channel_id FROM channel_participants WHERE participant_type = 'user' AND participant_id = $1 + )) AND c.is_archived = $2` countArgs := []interface{}{userID, archived == "true"} argN := 3 @@ -160,26 +176,26 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) { countArgs = append(countArgs, strings.TrimSpace(t)) argN++ } - countQuery += " AND type IN (" + strings.Join(placeholders, ",") + ")" + countQuery += " AND c.type IN (" + strings.Join(placeholders, ",") + ")" } else if channelType != "" { - countQuery += ` AND type = $` + strconv.Itoa(argN) + countQuery += ` AND c.type = $` + strconv.Itoa(argN) countArgs = append(countArgs, channelType) argN++ } if folder != "" { - countQuery += ` AND folder = $` + strconv.Itoa(argN) + countQuery += ` AND c.folder = $` + strconv.Itoa(argN) countArgs = append(countArgs, folder) argN++ } if search != "" { - countQuery += ` AND title ILIKE $` + strconv.Itoa(argN) + countQuery += ` AND c.title ILIKE $` + strconv.Itoa(argN) countArgs = append(countArgs, "%"+search+"%") argN++ } if projectFilter == "none" { - countQuery += ` AND project_id IS NULL` + countQuery += ` AND c.project_id IS NULL` } else if projectFilter != "" { - countQuery += ` AND project_id = $` + strconv.Itoa(argN) + countQuery += ` AND c.project_id = $` + strconv.Itoa(argN) countArgs = append(countArgs, projectFilter) argN++ } @@ -191,8 +207,10 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) { } // Fetch channels with message count + // Include channels owned by user OR where user is a participant (multi-user) query := ` - SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.provider_config_id, + SELECT c.id, c.user_id, c.title, c.type, c.ai_mode, c.topic, + c.description, c.model, c.provider_config_id, c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.project_id, c.workspace_id, c.tags, c.settings, COALESCE(mc.cnt, 0) AS message_count, @@ -201,7 +219,9 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) { LEFT JOIN ( SELECT channel_id, COUNT(*) AS cnt FROM messages GROUP BY channel_id ) mc ON mc.channel_id = c.id - WHERE c.user_id = $1 AND c.is_archived = $2` + WHERE (c.user_id = $1 OR c.id IN ( + SELECT channel_id FROM channel_participants WHERE participant_type = 'user' AND participant_id = $1 + )) AND c.is_archived = $2` args := []interface{}{userID, archived == "true"} argN = 3 @@ -252,7 +272,8 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) { var ch channelResponse var tags []string err := rows.Scan( - &ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.ProviderConfigID, + &ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.AiMode, &ch.Topic, + &ch.Description, &ch.Model, &ch.ProviderConfigID, &ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID, &ch.WorkspaceID, scanTags(&tags), scanJSON(&ch.Settings), &ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt, @@ -267,6 +288,18 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) { ch.Tags = tags channels = append(channels, ch) } + rows.Close() + + // v0.23.2: Compute unread counts per channel (safe post-processing) + for i := range channels { + _ = database.DB.QueryRowContext(c.Request.Context(), database.Q(` + SELECT COUNT(*) FROM messages m + JOIN channel_participants cp ON cp.channel_id = m.channel_id + WHERE cp.channel_id = $1 + AND cp.participant_type = 'user' AND cp.participant_id = $2 + AND m.created_at > cp.last_read_at + `), channels[i].ID, userID).Scan(&channels[i].UnreadCount) + } SafeJSON(c, http.StatusOK, paginatedResponse{ Data: channels, @@ -298,6 +331,62 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) { channelType = "direct" } + // Default ai_mode: mention_only for DMs, auto for everything else + aiMode := req.AiMode + if aiMode == "" { + if channelType == "dm" { + aiMode = "mention_only" + } else { + aiMode = "auto" + } + } + + // ── DM dedup: check for existing DM between these two users ── + if channelType == "dm" && len(req.Participants) == 1 { + otherUserID := req.Participants[0] + if otherUserID == userID { + c.JSON(http.StatusBadRequest, gin.H{"error": "cannot create DM with yourself"}) + return + } + + // Look for an existing DM channel where both users are participants + var existingID string + _ = database.DB.QueryRow(database.Q(` + SELECT cp1.channel_id FROM channel_participants cp1 + JOIN channel_participants cp2 ON cp1.channel_id = cp2.channel_id + JOIN channels c ON c.id = cp1.channel_id + WHERE c.type = 'dm' + AND cp1.participant_type = 'user' AND cp1.participant_id = $1 + AND cp2.participant_type = 'user' AND cp2.participant_id = $2 + LIMIT 1 + `), userID, otherUserID).Scan(&existingID) + if existingID != "" { + // Return existing channel instead of creating duplicate + var ch channelResponse + var tags []string + err := database.DB.QueryRow(database.Q(` + SELECT id, user_id, title, type, description, model, provider_config_id, + system_prompt, is_archived, is_pinned, folder, project_id, workspace_id, + tags, settings, + created_at, updated_at + FROM channels WHERE id = $1 + `), existingID).Scan( + &ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.ProviderConfigID, + &ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID, &ch.WorkspaceID, + scanTags(&tags), scanJSON(&ch.Settings), &ch.CreatedAt, &ch.UpdatedAt, + ) + if err == nil { + if tags == nil { + tags = []string{} + } + ch.Tags = tags + SafeJSON(c, http.StatusOK, ch) // 200, not 201 — existing resource + return + } + // If read fails, fall through and create new (shouldn't happen) + } + } + // INSERT and retrieve the new row var ch channelResponse var tags []string @@ -306,10 +395,10 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) { id := store.NewID() _, err := database.DB.Exec(` INSERT INTO channels (id, user_id, title, type, description, model, - system_prompt, provider_config_id, folder, tags) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + system_prompt, provider_config_id, folder, tags, ai_mode) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id, userID, req.Title, channelType, req.Description, req.Model, - req.SystemPrompt, req.ProviderConfigID, req.Folder, writeTagsArg(req.Tags), + req.SystemPrompt, req.ProviderConfigID, req.Folder, writeTagsArg(req.Tags), aiMode, ) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create channel"}) @@ -332,12 +421,12 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) { } } else { err := database.DB.QueryRow(` - INSERT INTO channels (user_id, title, type, description, model, system_prompt, provider_config_id, folder, tags) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + INSERT INTO channels (user_id, title, type, description, model, system_prompt, provider_config_id, folder, tags, ai_mode) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id, user_id, title, type, description, model, provider_config_id, system_prompt, is_archived, is_pinned, folder, project_id, workspace_id, tags, settings, created_at, updated_at `, userID, req.Title, channelType, req.Description, req.Model, req.SystemPrompt, req.ProviderConfigID, - req.Folder, pq.Array(req.Tags), + req.Folder, pq.Array(req.Tags), aiMode, ).Scan( &ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.ProviderConfigID, &ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID, &ch.WorkspaceID, @@ -370,6 +459,28 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) { `, ch.ID, userID) } + // v0.23.2: Add DM partner as member participant + if channelType == "dm" && len(req.Participants) > 0 { + for _, pid := range req.Participants { + if pid == userID { + continue // skip self (already added as owner) + } + if database.IsSQLite() { + _, _ = database.DB.Exec(` + INSERT INTO channel_participants (id, channel_id, participant_type, participant_id, role) + VALUES (?, ?, 'user', ?, 'member') + ON CONFLICT DO NOTHING + `, store.NewID(), ch.ID, pid) + } else { + _, _ = database.DB.Exec(` + INSERT INTO channel_participants (channel_id, participant_type, participant_id, role) + VALUES ($1, 'user', $2, 'member') + ON CONFLICT DO NOTHING + `, ch.ID, pid) + } + } + } + // Auto-create channel_model if model specified if req.Model != "" { if database.IsSQLite() { @@ -519,6 +630,17 @@ func (h *ChannelHandler) UpdateChannel(c *gin.Context) { addClause("workspace_id", *req.WorkspaceID) } } + if req.AiMode != nil { + mode := *req.AiMode + if mode != "auto" && mode != "mention_only" && mode != "off" { + c.JSON(http.StatusBadRequest, gin.H{"error": "ai_mode must be auto, mention_only, or off"}) + return + } + addClause("ai_mode", mode) + } + if req.Topic != nil { + addClause("topic", *req.Topic) + } if len(setClauses) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) @@ -571,3 +693,38 @@ func (h *ChannelHandler) DeleteChannel(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "channel deleted"}) } + +// ── Mark Read (v0.23.2) ─────────────────────── + +func (h *ChannelHandler) MarkRead(c *gin.Context) { + userID := getUserID(c) + channelID := c.Param("id") + + // Update the read cursor timestamp (last_read_at exists since migration 005) + _, err := database.DB.ExecContext(c.Request.Context(), database.Q(` + UPDATE channel_participants + SET last_read_at = NOW() + WHERE channel_id = $1 AND participant_type = 'user' AND participant_id = $2 + `), channelID, userID) + if err != nil { + // Participant row may not exist for legacy direct chats — that's OK + c.JSON(http.StatusOK, gin.H{"ok": true}) + return + } + + // Best-effort: also set last_read_message_id if the column exists (migration 017) + var latestMsgID *string + _ = database.DB.QueryRowContext(c.Request.Context(), database.Q(` + SELECT id FROM messages WHERE channel_id = $1 ORDER BY created_at DESC LIMIT 1 + `), channelID).Scan(&latestMsgID) + if latestMsgID != nil { + // This may fail if 017 hasn't been applied — that's fine, last_read_at is primary + _, _ = database.DB.ExecContext(c.Request.Context(), database.Q(` + UPDATE channel_participants + SET last_read_message_id = $1 + WHERE channel_id = $2 AND participant_type = 'user' AND participant_id = $3 + `), *latestMsgID, channelID, userID) + } + + c.JSON(http.StatusOK, gin.H{"ok": true}) +} diff --git a/server/handlers/completion.go b/server/handlers/completion.go index e5e6159..840a303 100644 --- a/server/handlers/completion.go +++ b/server/handlers/completion.go @@ -224,8 +224,43 @@ func (h *CompletionHandler) Complete(c *gin.Context) { return } if aiMode == "mention_only" && extractFirstMention(req.Content) == "" { - // Deliver message without triggering completion - c.JSON(http.StatusOK, gin.H{"status": "delivered", "ai_skipped": true}) + // Persist the user message before returning + msgID, err := h.persistMessage(channelID, userID, "user", req.Content, "", 0, 0, nil, nil, "", "") + if err != nil { + log.Printf("[mention_only] Failed to persist user message: %v", err) + } + // Broadcast via WS to ALL user participants (not just sender) + if h.hub != nil && msgID != "" { + payload, _ := json.Marshal(map[string]any{ + "id": msgID, + "channel_id": channelID, + "role": "user", + "content": req.Content, + "user_id": userID, + }) + evt := events.Event{ + Label: "message.created", + Payload: payload, + Ts: time.Now().UnixMilli(), + } + // Send to sender + h.hub.SendToUser(userID, evt) + // Send to other user participants in the channel + pRows, pErr := database.DB.QueryContext(c.Request.Context(), database.Q(` + SELECT participant_id FROM channel_participants + WHERE channel_id = $1 AND participant_type = 'user' AND participant_id != $2 + `), channelID, userID) + if pErr == nil { + defer pRows.Close() + for pRows.Next() { + var pid string + if pRows.Scan(&pid) == nil { + h.hub.SendToUser(pid, evt) + } + } + } + } + c.JSON(http.StatusOK, gin.H{"status": "delivered", "ai_skipped": true, "message_id": msgID}) return } } @@ -234,6 +269,40 @@ func (h *CompletionHandler) Complete(c *gin.Context) { var personaSystemPrompt string var personaID string // tracks active persona for KB scoping var personaThinkingBudget *int // persona-level thinking budget for hook injection (v0.22.1) + + // v0.23.2: Group leader default — when type=group and no @mention, use the leader persona + if req.PersonaID == "" && extractFirstMention(req.Content) == "" { + var channelType string + _ = database.DB.QueryRowContext(c.Request.Context(), database.Q(` + SELECT COALESCE(type, 'direct') FROM channels WHERE id = $1 + `), channelID).Scan(&channelType) + if channelType == "group" { + // Find the leader persona via channel_participants → persona_group_members + var leaderPersonaID string + _ = database.DB.QueryRowContext(c.Request.Context(), database.Q(` + SELECT cp.participant_id + FROM channel_participants cp + JOIN persona_group_members pgm ON pgm.persona_id = cp.participant_id + WHERE cp.channel_id = $1 + AND cp.participant_type = 'persona' + AND pgm.is_leader = true + LIMIT 1 + `), channelID).Scan(&leaderPersonaID) + // Fallback: first persona participant if no leader flag set + if leaderPersonaID == "" { + _ = database.DB.QueryRowContext(c.Request.Context(), database.Q(` + SELECT participant_id FROM channel_participants + WHERE channel_id = $1 AND participant_type = 'persona' + ORDER BY created_at LIMIT 1 + `), channelID).Scan(&leaderPersonaID) + } + if leaderPersonaID != "" { + req.PersonaID = leaderPersonaID + log.Printf("[group] channel %s: leader persona %s", channelID[:min(8, len(channelID))], leaderPersonaID[:min(8, len(leaderPersonaID))]) + } + } + } + if req.PersonaID != "" { persona := ResolvePersona(h.stores, req.PersonaID, userID) if persona == nil { @@ -413,14 +482,60 @@ func (h *CompletionHandler) Complete(c *gin.Context) { // - AI mention → override model/persona for this turn (v0.23.0) mentionModel, mentionConfig, mentionPersona, mentionedUserID := h.resolveMention(c.Request.Context(), userID, req.Content) - if mentionedUserID != "" { - // Human @mention — message already persisted; notify, skip AI. - if hub, ok := c.Get("events_hub"); ok { - if h2, ok2 := hub.(interface { - NotifyUserMention(toUser, channel, fromUser string) - }); ok2 { - h2.NotifyUserMention(mentionedUserID, channelID, userID) + // v0.23.2: @all fan-out — route to every persona participant (depth-1 only) + if strings.Contains(strings.ToLower(req.Content), "@all") { + // Get all persona participants for this channel + allRows, err := database.DB.QueryContext(c.Request.Context(), database.Q(` + SELECT participant_id FROM channel_participants + WHERE channel_id = $1 AND participant_type = 'persona' + ORDER BY created_at + `), channelID) + if err == nil { + defer allRows.Close() + var allPersonaIDs []string + for allRows.Next() { + var pid string + if allRows.Scan(&pid) == nil { + allPersonaIDs = append(allPersonaIDs, pid) + } } + if len(allPersonaIDs) > 0 { + // Use the first persona for the streamed response + first := ResolvePersona(h.stores, allPersonaIDs[0], userID) + if first != nil { + mentionModel = first.BaseModelID + if first.ProviderConfigID != nil { + mentionConfig = *first.ProviderConfigID + } + mentionPersona = first + } + // Chain remaining personas via goroutine after the stream completes + if len(allPersonaIDs) > 1 && h.hub != nil { + remaining := allPersonaIDs[1:] + go func() { + for _, pid := range remaining { + time.Sleep(500 * time.Millisecond) + h.chainToPersona(channelID, userID, pid, req.Content, 1) + } + }() + } + } + } + } + + if mentionedUserID != "" { + // Human @mention — message already persisted; notify recipient, skip AI. + if h.hub != nil { + payload, _ := json.Marshal(map[string]any{ + "channel_id": channelID, + "from_user": userID, + "content": truncateContent(req.Content, 120), + }) + h.hub.SendToUser(mentionedUserID, events.Event{ + Label: "user.mentioned", + Payload: payload, + Ts: time.Now().UnixMilli(), + }) } c.JSON(http.StatusOK, gin.H{"status": "delivered", "mentioned_user": mentionedUserID}) return @@ -1230,7 +1345,7 @@ func (h *CompletionHandler) resolveMention(ctx context.Context, userID, content var mentionedUserID string err = database.DB.QueryRowContext(ctx, database.Q(` SELECT id FROM users - WHERE LOWER(username) = $1 AND id != $2 AND is_approved = true + WHERE LOWER(username) = $1 AND id != $2 AND is_active = true LIMIT 1 `), normalized, userID).Scan(&mentionedUserID) if err == nil && mentionedUserID != "" { @@ -1242,12 +1357,12 @@ func (h *CompletionHandler) resolveMention(ctx context.Context, userID, content var userCount int database.DB.QueryRowContext(ctx, database.Q(` SELECT COUNT(*) FROM users - WHERE LOWER(username) LIKE $1 AND id != $2 AND is_approved = true + WHERE LOWER(username) LIKE $1 AND id != $2 AND is_active = true `), normalized+"%", userID).Scan(&userCount) if userCount == 1 { database.DB.QueryRowContext(ctx, database.Q(` SELECT id FROM users - WHERE LOWER(username) LIKE $1 AND id != $2 AND is_approved = true + WHERE LOWER(username) LIKE $1 AND id != $2 AND is_active = true LIMIT 1 `), normalized+"%", userID).Scan(&mentionedUserID) if mentionedUserID != "" { @@ -1820,3 +1935,12 @@ func (h *CompletionHandler) recordHealth(configID string, start time.Time, err e h.health.RecordSuccess(configID, latencyMs) } } + +// truncateContent returns content truncated to maxLen runes with ellipsis. +func truncateContent(s string, maxLen int) string { + runes := []rune(s) + if len(runes) <= maxLen { + return s + } + return string(runes[:maxLen]) + "…" +} diff --git a/server/handlers/completion_chain.go b/server/handlers/completion_chain.go index 83852eb..8425f02 100644 --- a/server/handlers/completion_chain.go +++ b/server/handlers/completion_chain.go @@ -203,6 +203,141 @@ func (h *CompletionHandler) chainIfMentioned( h.chainIfMentioned(channelID, userID, mentionPersona.ID, resp.Content, depth+1) } +// chainToPersona triggers a completion for a specific persona by ID. +// Used by @all fan-out. Does NOT recurse (depth-1 only). +func (h *CompletionHandler) chainToPersona(channelID, userID, personaID, triggerContent string, depth int) { + defer func() { + if r := recover(); r != nil { + log.Printf("[chain-all] PANIC: %v", r) + } + }() + + persona := ResolvePersona(h.stores, personaID, userID) + if persona == nil { + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + + if h.hub != nil { + h.emitTyping(userID, channelID, persona.ID, persona.Name, true) + defer h.emitTyping(userID, channelID, persona.ID, persona.Name, false) + } + + configID := "" + if persona.ProviderConfigID != nil { + configID = *persona.ProviderConfigID + } + chainReq := completionRequest{ + ChannelID: channelID, + Model: persona.BaseModelID, + ProviderConfigID: configID, + } + providerCfg, providerID, model, resolvedConfigID, _, err := h.resolveConfig(userID, channelID, chainReq) + if err != nil { + log.Printf("[chain-all] Config failed for %s: %v", persona.Name, err) + return + } + configID = resolvedConfigID + + provider, err := providers.Get(providerID) + if err != nil { + return + } + + messages, err := h.loadConversation(channelID, userID, persona.SystemPrompt, persona.ID, model) + if err != nil { + return + } + + boundary := fmt.Sprintf( + "[You are now %s. Previous assistant messages may be from different AI personas — ignore their style. Respond only as yourself per your system prompt.]", + persona.Name, + ) + messages = append(messages, providers.Message{Role: "system", Content: boundary}) + + caps := capspkg.ResolveIntrinsic(model, nil, nil) + provReq := providers.CompletionRequest{ + Model: model, + Messages: messages, + MaxTokens: capspkg.ResolveMaxOutput(model, caps), + } + if persona.Temperature != nil { + provReq.Temperature = persona.Temperature + } + if persona.ThinkingBudget != nil && *persona.ThinkingBudget > 0 { + if providerCfg.Settings == nil { + providerCfg.Settings = make(map[string]interface{}) + } + providerCfg.Settings["extended_thinking"] = true + providerCfg.Settings["thinking_budget"] = *persona.ThinkingBudget + } + + if hooks := providers.GetHooks(providerID); hooks != nil { + hooks.PreRequest(providerCfg, &provReq) + } + + callStart := time.Now() + resp, err := provider.ChatCompletion(ctx, providerCfg, provReq) + latencyMs := int(time.Since(callStart).Milliseconds()) + if err != nil { + if h.health != nil { + h.health.RecordError(configID, latencyMs, err.Error()) + } + return + } + if h.health != nil { + h.health.RecordSuccess(configID, latencyMs) + } + if resp.Content == "" { + return + } + + msgID, err := h.persistMessage(channelID, userID, "assistant", resp.Content, model, + resp.InputTokens, resp.OutputTokens, nil, nil, configID, persona.ID) + if err != nil { + return + } + + if h.hub != nil { + payload, _ := json.Marshal(map[string]interface{}{ + "id": msgID, + "channel_id": channelID, + "role": "assistant", + "content": resp.Content, + "model": model, + "participant_type": "persona", + "participant_id": persona.ID, + "display_name": persona.Name, + "avatar": persona.Avatar, + "tokens_used": resp.InputTokens + resp.OutputTokens, + "chain_depth": depth, + }) + h.hub.SendToUser(userID, events.Event{ + Label: "message.created", + Payload: payload, + Ts: time.Now().UnixMilli(), + }) + } + + if h.stores.Usage != nil { + entry := &models.UsageEntry{ + ChannelID: &channelID, + UserID: userID, + ProviderConfigID: &configID, + ProviderScope: "global", + ModelID: model, + PromptTokens: resp.InputTokens, + CompletionTokens: resp.OutputTokens, + } + _ = h.stores.Usage.Log(ctx, entry) + } + + log.Printf("[chain-all] ✅ %s responded (%d tokens) in channel %s", + persona.Name, resp.InputTokens+resp.OutputTokens, channelID[:min(8, len(channelID))]) +} + // emitTyping sends a typing indicator via WebSocket. func (h *CompletionHandler) emitTyping(userID, channelID, participantID, displayName string, start bool) { eventType := "typing.start" diff --git a/server/handlers/messages.go b/server/handlers/messages.go index 5abf385..2d3ddeb 100644 --- a/server/handlers/messages.go +++ b/server/handlers/messages.go @@ -29,16 +29,20 @@ type createMessageRequest struct { } type messageResponse struct { - ID string `json:"id"` - ChannelID string `json:"channel_id"` - Role string `json:"role"` - Content string `json:"content"` - Model *string `json:"model"` - TokensUsed *int `json:"tokens_used"` - ParentID *string `json:"parent_id,omitempty"` - SiblingCount int `json:"sibling_count"` - SiblingIndex int `json:"sibling_index"` - CreatedAt string `json:"created_at"` + ID string `json:"id"` + ChannelID string `json:"channel_id"` + Role string `json:"role"` + Content string `json:"content"` + Model *string `json:"model"` + TokensUsed *int `json:"tokens_used"` + ParentID *string `json:"parent_id,omitempty"` + SiblingCount int `json:"sibling_count"` + SiblingIndex int `json:"sibling_index"` + ParticipantType *string `json:"participant_type,omitempty"` + ParticipantID *string `json:"participant_id,omitempty"` + SenderName *string `json:"sender_name,omitempty"` + SenderAvatar *string `json:"sender_avatar,omitempty"` + CreatedAt string `json:"created_at"` } type editRequest struct { @@ -98,11 +102,20 @@ func (h *MessageHandler) ListMessages(c *gin.Context) { } rows, err := database.DB.Query(database.Q(` - SELECT id, channel_id, role, content, model, tokens_used, parent_id, - sibling_index, created_at - FROM messages - WHERE channel_id = $1 AND deleted_at IS NULL - ORDER BY created_at ASC + SELECT m.id, m.channel_id, m.role, m.content, m.model, m.tokens_used, m.parent_id, + m.sibling_index, m.participant_type, m.participant_id, + CASE WHEN m.participant_type = 'user' THEN COALESCE(u.display_name, u.username) + WHEN m.participant_type = 'persona' THEN p.name + ELSE NULL END AS sender_name, + CASE WHEN m.participant_type = 'user' THEN u.avatar_url + WHEN m.participant_type = 'persona' THEN p.avatar + ELSE NULL END AS sender_avatar, + m.created_at + FROM messages m + LEFT JOIN users u ON m.participant_type = 'user' AND m.participant_id = u.id::text + LEFT JOIN personas p ON m.participant_type = 'persona' AND m.participant_id = p.id::text + WHERE m.channel_id = $1 AND m.deleted_at IS NULL + ORDER BY m.created_at ASC LIMIT $2 OFFSET $3 `), channelID, perPage, offset) if err != nil { @@ -117,7 +130,9 @@ func (h *MessageHandler) ListMessages(c *gin.Context) { err := rows.Scan( &msg.ID, &msg.ChannelID, &msg.Role, &msg.Content, &msg.Model, &msg.TokensUsed, &msg.ParentID, - &msg.SiblingIndex, &msg.CreatedAt, + &msg.SiblingIndex, &msg.ParticipantType, &msg.ParticipantID, + &msg.SenderName, &msg.SenderAvatar, + &msg.CreatedAt, ) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to scan message"}) @@ -693,9 +708,20 @@ func userOwnsChannel(c *gin.Context, channelID, userID string) bool { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify channel ownership"}) return false } - if ownerID != userID { - c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"}) - return false + if ownerID == userID { + return true } - return true + + // v0.23.2: Also allow if user is a participant (multi-user DMs, channels) + var exists bool + _ = database.DB.QueryRow( + database.Q(`SELECT EXISTS(SELECT 1 FROM channel_participants WHERE channel_id = $1 AND participant_type = 'user' AND participant_id = $2)`), + channelID, userID, + ).Scan(&exists) + if exists { + return true + } + + c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"}) + return false } diff --git a/server/handlers/participants.go b/server/handlers/participants.go index cd3eb19..7b2b3ee 100644 --- a/server/handlers/participants.go +++ b/server/handlers/participants.go @@ -6,6 +6,7 @@ import ( "github.com/gin-gonic/gin" + "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/store" ) @@ -258,6 +259,37 @@ func (h *ParticipantHandler) Remove(c *gin.Context) { } } + // v0.23.2: DM guard — cannot drop below 2 human participants + var channelType string + _ = database.DB.QueryRowContext(c.Request.Context(), database.Q(` + SELECT COALESCE(type, 'direct') FROM channels WHERE id = $1 + `), channelID).Scan(&channelType) + + if channelType == "dm" && p.ParticipantType == "user" { + var userCount int + _ = database.DB.QueryRowContext(c.Request.Context(), database.Q(` + SELECT COUNT(*) FROM channel_participants + WHERE channel_id = $1 AND participant_type = 'user' + `), channelID).Scan(&userCount) + if userCount <= 2 { + c.JSON(http.StatusConflict, gin.H{"error": "DMs require at least 2 participants"}) + return + } + } + + // v0.23.2: Group guard — cannot remove the last persona + if channelType == "group" && p.ParticipantType == "persona" { + var personaCount int + _ = database.DB.QueryRowContext(c.Request.Context(), database.Q(` + SELECT COUNT(*) FROM channel_participants + WHERE channel_id = $1 AND participant_type = 'persona' + `), channelID).Scan(&personaCount) + if personaCount <= 1 { + c.JSON(http.StatusConflict, gin.H{"error": "group chats require at least 1 persona"}) + return + } + } + // If removing a persona, also remove its auto-created model roster entry if p.ParticipantType == "persona" { _ = h.stores.Channels.DeleteModelByPersona(c.Request.Context(), channelID, p.ParticipantID) diff --git a/server/handlers/persona_groups.go b/server/handlers/persona_groups.go new file mode 100644 index 0000000..0d1eab2 --- /dev/null +++ b/server/handlers/persona_groups.go @@ -0,0 +1,311 @@ +package handlers + +// persona_groups.go — Persona group (roster template) CRUD (v0.23.2) +// +// Persona groups are saved collections of personas used as templates +// for creating group chats. Each member has an is_leader flag. +// +// Routes: +// GET /api/v1/persona-groups +// POST /api/v1/persona-groups +// GET /api/v1/persona-groups/:id +// PUT /api/v1/persona-groups/:id +// DELETE /api/v1/persona-groups/:id +// POST /api/v1/persona-groups/:id/members +// DELETE /api/v1/persona-groups/:id/members/:memberId + +import ( + "database/sql" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + + "git.gobha.me/xcaliber/chat-switchboard/database" + "git.gobha.me/xcaliber/chat-switchboard/models" + "git.gobha.me/xcaliber/chat-switchboard/store" +) + +type PersonaGroupHandler struct{} + +func NewPersonaGroupHandler() *PersonaGroupHandler { return &PersonaGroupHandler{} } + +// ── List ──────────────────────────────────────── + +func (h *PersonaGroupHandler) List(c *gin.Context) { + userID := getUserID(c) + + rows, err := database.DB.QueryContext(c.Request.Context(), database.Q(` + SELECT id, name, description, owner_id, scope, team_id, created_at, updated_at + FROM persona_groups + WHERE owner_id = $1 + ORDER BY name + `), userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list groups"}) + return + } + defer rows.Close() + + groups := []models.PersonaGroup{} + for rows.Next() { + var g models.PersonaGroup + if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.OwnerID, + &g.Scope, &g.TeamID, &g.CreatedAt, &g.UpdatedAt); err != nil { + continue + } + groups = append(groups, g) + } + + // Load members for each group + for i := range groups { + groups[i].Members = h.loadMembers(c, groups[i].ID) + } + + c.JSON(http.StatusOK, gin.H{"groups": groups}) +} + +// ── Get ───────────────────────────────────────── + +func (h *PersonaGroupHandler) Get(c *gin.Context) { + userID := getUserID(c) + id := c.Param("id") + + var g models.PersonaGroup + err := database.DB.QueryRowContext(c.Request.Context(), database.Q(` + SELECT id, name, description, owner_id, scope, team_id, created_at, updated_at + FROM persona_groups WHERE id = $1 AND owner_id = $2 + `), id, userID).Scan(&g.ID, &g.Name, &g.Description, &g.OwnerID, + &g.Scope, &g.TeamID, &g.CreatedAt, &g.UpdatedAt) + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "group not found"}) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get group"}) + return + } + + g.Members = h.loadMembers(c, g.ID) + c.JSON(http.StatusOK, g) +} + +// ── Create ────────────────────────────────────── + +func (h *PersonaGroupHandler) Create(c *gin.Context) { + userID := getUserID(c) + + var req struct { + Name string `json:"name" binding:"required,min=1,max=100"` + Description string `json:"description"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var g models.PersonaGroup + if database.IsSQLite() { + id := store.NewID() + _, err := database.DB.ExecContext(c.Request.Context(), ` + INSERT INTO persona_groups (id, name, description, owner_id, scope) + VALUES (?, ?, ?, ?, 'personal') + `, id, strings.TrimSpace(req.Name), req.Description, userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create group"}) + return + } + database.DB.QueryRowContext(c.Request.Context(), ` + SELECT id, name, description, owner_id, scope, team_id, created_at, updated_at + FROM persona_groups WHERE id = ? + `, id).Scan(&g.ID, &g.Name, &g.Description, &g.OwnerID, + &g.Scope, &g.TeamID, &g.CreatedAt, &g.UpdatedAt) + } else { + err := database.DB.QueryRowContext(c.Request.Context(), ` + INSERT INTO persona_groups (name, description, owner_id, scope) + VALUES ($1, $2, $3, 'personal') + RETURNING id, name, description, owner_id, scope, team_id, created_at, updated_at + `, strings.TrimSpace(req.Name), req.Description, userID).Scan( + &g.ID, &g.Name, &g.Description, &g.OwnerID, + &g.Scope, &g.TeamID, &g.CreatedAt, &g.UpdatedAt) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create group"}) + return + } + } + + g.Members = []models.PersonaGroupMember{} + c.JSON(http.StatusCreated, g) +} + +// ── Update ────────────────────────────────────── + +func (h *PersonaGroupHandler) Update(c *gin.Context) { + userID := getUserID(c) + id := c.Param("id") + + var req struct { + Name *string `json:"name"` + Description *string `json:"description"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Verify ownership + var ownerID string + err := database.DB.QueryRowContext(c.Request.Context(), database.Q(` + SELECT owner_id FROM persona_groups WHERE id = $1 + `), id).Scan(&ownerID) + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "group not found"}) + return + } + if ownerID != userID { + c.JSON(http.StatusForbidden, gin.H{"error": "not your group"}) + return + } + + if req.Name != nil { + database.DB.ExecContext(c.Request.Context(), database.Q(` + UPDATE persona_groups SET name = $1, updated_at = NOW() WHERE id = $2 + `), strings.TrimSpace(*req.Name), id) + } + if req.Description != nil { + database.DB.ExecContext(c.Request.Context(), database.Q(` + UPDATE persona_groups SET description = $1, updated_at = NOW() WHERE id = $2 + `), *req.Description, id) + } + + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +// ── Delete ────────────────────────────────────── + +func (h *PersonaGroupHandler) Delete(c *gin.Context) { + userID := getUserID(c) + id := c.Param("id") + + result, err := database.DB.ExecContext(c.Request.Context(), database.Q(` + DELETE FROM persona_groups WHERE id = $1 AND owner_id = $2 + `), id, userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"}) + return + } + n, _ := result.RowsAffected() + if n == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "group not found"}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +// ── Add Member ────────────────────────────────── + +func (h *PersonaGroupHandler) AddMember(c *gin.Context) { + userID := getUserID(c) + groupID := c.Param("id") + + var req struct { + PersonaID string `json:"persona_id" binding:"required"` + IsLeader bool `json:"is_leader"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Verify ownership + var ownerID string + err := database.DB.QueryRowContext(c.Request.Context(), database.Q(` + SELECT owner_id FROM persona_groups WHERE id = $1 + `), groupID).Scan(&ownerID) + if err != nil || ownerID != userID { + c.JSON(http.StatusForbidden, gin.H{"error": "not your group"}) + return + } + + // If setting as leader, clear existing leader + if req.IsLeader { + database.DB.ExecContext(c.Request.Context(), database.Q(` + UPDATE persona_group_members SET is_leader = false WHERE group_id = $1 + `), groupID) + } + + if database.IsSQLite() { + id := store.NewID() + _, err = database.DB.ExecContext(c.Request.Context(), ` + INSERT INTO persona_group_members (id, group_id, persona_id, is_leader) + VALUES (?, ?, ?, ?) + ON CONFLICT (group_id, persona_id) DO UPDATE SET is_leader = excluded.is_leader + `, id, groupID, req.PersonaID, req.IsLeader) + } else { + _, err = database.DB.ExecContext(c.Request.Context(), ` + INSERT INTO persona_group_members (group_id, persona_id, is_leader) + VALUES ($1, $2, $3) + ON CONFLICT (group_id, persona_id) DO UPDATE SET is_leader = EXCLUDED.is_leader + `, groupID, req.PersonaID, req.IsLeader) + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add member"}) + return + } + + c.JSON(http.StatusCreated, gin.H{"ok": true}) +} + +// ── Remove Member ─────────────────────────────── + +func (h *PersonaGroupHandler) RemoveMember(c *gin.Context) { + userID := getUserID(c) + groupID := c.Param("id") + memberID := c.Param("memberId") + + // Verify ownership + var ownerID string + err := database.DB.QueryRowContext(c.Request.Context(), database.Q(` + SELECT owner_id FROM persona_groups WHERE id = $1 + `), groupID).Scan(&ownerID) + if err != nil || ownerID != userID { + c.JSON(http.StatusForbidden, gin.H{"error": "not your group"}) + return + } + + database.DB.ExecContext(c.Request.Context(), database.Q(` + DELETE FROM persona_group_members WHERE id = $1 AND group_id = $2 + `), memberID, groupID) + + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +// ── Helpers ───────────────────────────────────── + +func (h *PersonaGroupHandler) loadMembers(c *gin.Context, groupID string) []models.PersonaGroupMember { + rows, err := database.DB.QueryContext(c.Request.Context(), database.Q(` + SELECT pgm.id, pgm.group_id, pgm.persona_id, pgm.is_leader, pgm.sort_order, + COALESCE(p.name, '') AS persona_name, + COALESCE(p.handle, '') AS persona_handle, + COALESCE(p.avatar, '') AS persona_avatar + FROM persona_group_members pgm + LEFT JOIN personas p ON p.id = pgm.persona_id + WHERE pgm.group_id = $1 + ORDER BY pgm.is_leader DESC, pgm.sort_order, pgm.id + `), groupID) + if err != nil { + return []models.PersonaGroupMember{} + } + defer rows.Close() + + members := []models.PersonaGroupMember{} + for rows.Next() { + var m models.PersonaGroupMember + if err := rows.Scan(&m.ID, &m.GroupID, &m.PersonaID, &m.IsLeader, &m.SortOrder, + &m.PersonaName, &m.PersonaHandle, &m.PersonaAvatar); err != nil { + continue + } + members = append(members, m) + } + return members +} diff --git a/server/handlers/presence.go b/server/handlers/presence.go index 04fa959..b80dc38 100644 --- a/server/handlers/presence.go +++ b/server/handlers/presence.go @@ -67,3 +67,50 @@ func PresenceQuery(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{"presence": result}) } + +// SearchUsers returns a lightweight list of approved users matching a query. +// GET /api/v1/users/search?q=alice — matches against username and display_name. +// Returns at most 20 results. Excludes the calling user. +func SearchUsers(c *gin.Context) { + userID := getUserID(c) + q := strings.TrimSpace(c.Query("q")) + + query := database.Q(` + SELECT id, username, COALESCE(display_name, '') AS display_name + FROM users + WHERE is_active = true AND id != $1 + `) + args := []interface{}{userID} + + if q != "" { + query += database.Q(` AND (LOWER(username) LIKE $2 OR LOWER(display_name) LIKE $3)`) + pattern := "%" + strings.ToLower(q) + "%" + args = append(args, pattern, pattern) + } + + query += ` ORDER BY username LIMIT 20` + + rows, err := database.DB.QueryContext(c.Request.Context(), query, args...) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "search failed"}) + return + } + defer rows.Close() + + type userResult struct { + ID string `json:"id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + } + + results := []userResult{} + for rows.Next() { + var u userResult + if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName); err != nil { + continue + } + results = append(results, u) + } + + c.JSON(http.StatusOK, gin.H{"users": results}) +} diff --git a/server/main.go b/server/main.go index 4c7e05d..30928b9 100644 --- a/server/main.go +++ b/server/main.go @@ -376,6 +376,46 @@ func main() { protected.GET("/channels/:id", channels.GetChannel) protected.PUT("/channels/:id", channels.UpdateChannel) protected.DELETE("/channels/:id", channels.DeleteChannel) + protected.POST("/channels/:id/mark-read", channels.MarkRead) + + // Typing indicator broadcast (v0.23.2) + protected.POST("/channels/:id/typing", func(c *gin.Context) { + userID := c.GetString("user_id") + channelID := c.Param("id") + // Resolve display name + var displayName string + _ = database.DB.QueryRow(database.Q(` + SELECT COALESCE(display_name, username) FROM users WHERE id = $1 + `), userID).Scan(&displayName) + if displayName == "" { + displayName = userID[:8] + } + // Broadcast to other user participants + pRows, err := database.DB.Query(database.Q(` + SELECT participant_id FROM channel_participants + WHERE channel_id = $1 AND participant_type = 'user' AND participant_id != $2 + `), channelID, userID) + if err == nil { + defer pRows.Close() + payload, _ := json.Marshal(map[string]any{ + "channel_id": channelID, + "user_id": userID, + "display_name": displayName, + }) + evt := events.Event{ + Label: "typing.user", + Payload: payload, + Ts: time.Now().UnixMilli(), + } + for pRows.Next() { + var pid string + if pRows.Scan(&pid) == nil { + hub.SendToUser(pid, evt) + } + } + } + c.JSON(200, gin.H{"ok": true}) + }) // Chat Folders (v0.23.1) folders := handlers.NewFolderHandler() @@ -388,6 +428,19 @@ func main() { protected.POST("/presence/heartbeat", handlers.PresenceHeartbeat) protected.GET("/presence", handlers.PresenceQuery) + // User search (v0.23.2 — DM user picker) + protected.GET("/users/search", handlers.SearchUsers) + + // Persona groups (v0.23.2 — roster templates for group chats) + pgH := handlers.NewPersonaGroupHandler() + protected.GET("/persona-groups", pgH.List) + protected.POST("/persona-groups", pgH.Create) + protected.GET("/persona-groups/:id", pgH.Get) + protected.PUT("/persona-groups/:id", pgH.Update) + protected.DELETE("/persona-groups/:id", pgH.Delete) + protected.POST("/persona-groups/:id/members", pgH.AddMember) + protected.DELETE("/persona-groups/:id/members/:memberId", pgH.RemoveMember) + // Channel models (v0.20.0 — multi-model @mention routing) chModelH := handlers.NewChannelModelHandler(stores) protected.GET("/channels/:id/models", chModelH.List) @@ -802,6 +855,10 @@ func main() { admin.POST("/storage/cleanup", fileAdm.CleanupOrphans) admin.GET("/storage/extraction", fileAdm.ExtractionStatus) + // Archived channels (admin — v0.23.2) + admin.GET("/channels/archived", adm.ListArchivedChannels) + admin.DELETE("/channels/:id/purge", adm.PurgeChannel) + // Extensions (admin) extAdm := handlers.NewExtensionHandler(stores) admin.GET("/extensions", extAdm.AdminListExtensions) diff --git a/server/pages/templates/surfaces/chat.html b/server/pages/templates/surfaces/chat.html index cfa95f3..616bc65 100644 --- a/server/pages/templates/surfaces/chat.html +++ b/server/pages/templates/surfaces/chat.html @@ -160,7 +160,10 @@ window.addEventListener('unhandledrejection', function(e) { -
+
{{/* Populated by UI.renderChatList() */}}
@@ -264,6 +267,9 @@ window.addEventListener('unhandledrejection', function(e) { + {{/* v0.23.2: Channel context banner — shown for DMs and named channels */}} + + {{/* Messages */}}
{{/* Populated by UI.renderMessages() */}} diff --git a/server/pages/templates/surfaces/settings.html b/server/pages/templates/surfaces/settings.html index 1b5bd88..e2b3592 100644 --- a/server/pages/templates/surfaces/settings.html +++ b/server/pages/templates/surfaces/settings.html @@ -140,6 +140,10 @@
Loading personas…
+ {{else if eq .Section "models"}} +
Loading models…
+ {{else if eq .Section "teams"}} +
Loading teams…
{{else}}
Loading…
{{end}} diff --git a/server/treepath/path.go b/server/treepath/path.go index b5b14cb..e169344 100644 --- a/server/treepath/path.go +++ b/server/treepath/path.go @@ -25,6 +25,8 @@ type PathMessage struct { SiblingIndex int `json:"sibling_index"` ParticipantType string `json:"participant_type,omitempty"` ParticipantID string `json:"participant_id,omitempty"` + SenderName *string `json:"sender_name,omitempty"` + SenderAvatar *string `json:"sender_avatar,omitempty"` CreatedAt string `json:"created_at"` } @@ -159,5 +161,79 @@ func GetPathToLeaf(channelID, leafID string) ([]PathMessage, error) { path[i].SiblingCount = GetSiblingCount(channelID, path[i].ParentID) } + // v0.23.2: Resolve sender names and avatars + resolveSenderInfo(path) + return path, nil } + +// resolveSenderInfo batch-resolves sender_name and sender_avatar for path messages. +func resolveSenderInfo(path []PathMessage) { + // Collect unique participant IDs by type + userIDs := map[string]bool{} + personaIDs := map[string]bool{} + for _, m := range path { + if m.ParticipantID == "" { + continue + } + switch m.ParticipantType { + case "user": + userIDs[m.ParticipantID] = true + case "persona": + personaIDs[m.ParticipantID] = true + } + } + + // Resolve users + userNames := map[string]string{} + userAvatars := map[string]string{} + for uid := range userIDs { + var name, avatar sql.NullString + _ = database.DB.QueryRow(database.Q(` + SELECT COALESCE(display_name, username), avatar_url FROM users WHERE id = $1 + `), uid).Scan(&name, &avatar) + if name.Valid { + userNames[uid] = name.String + } + if avatar.Valid { + userAvatars[uid] = avatar.String + } + } + + // Resolve personas + personaNames := map[string]string{} + personaAvatars := map[string]string{} + for pid := range personaIDs { + var name, avatar sql.NullString + _ = database.DB.QueryRow(database.Q(` + SELECT name, avatar FROM personas WHERE id = $1 + `), pid).Scan(&name, &avatar) + if name.Valid { + personaNames[pid] = name.String + } + if avatar.Valid { + personaAvatars[pid] = avatar.String + } + } + + // Apply to path + for i := range path { + pid := path[i].ParticipantID + switch path[i].ParticipantType { + case "user": + if n, ok := userNames[pid]; ok { + path[i].SenderName = &n + } + if a, ok := userAvatars[pid]; ok && a != "" { + path[i].SenderAvatar = &a + } + case "persona": + if n, ok := personaNames[pid]; ok { + path[i].SenderName = &n + } + if a, ok := personaAvatars[pid]; ok && a != "" { + path[i].SenderAvatar = &a + } + } + } +} diff --git a/src/css/channel-models.css b/src/css/channel-models.css index 1360a05..857e260 100644 --- a/src/css/channel-models.css +++ b/src/css/channel-models.css @@ -191,6 +191,11 @@ border-radius: 4px; font-size: 0.92em; } +/* v0.23.2: User mention pill — teal to distinguish from AI mentions */ +.mention-pill.mention-user { + color: #2dd4bf; + background: rgba(45,212,191,0.12); +} /* ── Model Attribution (multi-model messages) ─ */ diff --git a/src/css/chat.css b/src/css/chat.css index e81e938..21aabac 100644 --- a/src/css/chat.css +++ b/src/css/chat.css @@ -32,6 +32,48 @@ display: inline-flex; align-items: center; gap: 4px; } +/* v0.23.2: Channel context banner */ +.channel-context-banner { + padding: 6px 16px; + font-size: 12px; + color: var(--text-3); + background: var(--bg-raised); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} +.channel-context-banner .ctx-mode { + font-family: var(--mono); + font-size: 11px; + padding: 1px 6px; + border-radius: 3px; + background: var(--bg-surface); + color: var(--text-2); +} +.channel-context-banner .ctx-mode-btn { + cursor: pointer; border: 1px solid var(--border); + transition: background 0.15s; +} +.channel-context-banner .ctx-mode-btn:hover { + background: var(--bg-hover); color: var(--text); +} +.channel-context-banner .ctx-topic { + color: var(--text-2); font-size: 12px; +} +.channel-context-banner .ctx-hint { + color: var(--text-3); + font-style: italic; +} +.ctx-participants-btn { + margin-left: auto; + background: none; border: 1px solid var(--border); border-radius: 4px; + color: var(--text-2); font-size: 11px; padding: 2px 8px; cursor: pointer; +} +.ctx-participants-btn:hover { background: var(--bg-hover); color: var(--text); } +.hover-bg:hover { background: var(--bg-hover); } + /* Message container (scrollable) */ .msg-container { flex: 1; overflow-y: auto; scroll-behavior: smooth; @@ -171,6 +213,16 @@ .message.user .msg-avatar { background: var(--accent-dim); } .message.assistant .msg-avatar { background: var(--bg-raised); } +/* Other human's messages — left-aligned like assistant, teal accent */ +.message.other-user .msg-body { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: 16px 16px 16px 4px; + padding: 10px 14px; +} +.message.other-user .msg-role { color: #2dd4bf; } +.message.other-user .msg-avatar { background: rgba(45,212,191,0.12); color: #2dd4bf; } + .msg-body { flex: 1; min-width: 0; } .msg-head { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; } .msg-role { font-size: 13px; font-weight: 600; } diff --git a/src/css/surfaces.css b/src/css/surfaces.css index 8ffe50a..10bc058 100644 --- a/src/css/surfaces.css +++ b/src/css/surfaces.css @@ -705,6 +705,14 @@ .sb-channel-item.active { background: var(--bg-raised); color: var(--text); } .sidebar.collapsed .sb-channel-item { justify-content: center; padding: 5px 0; margin: 0 6px; } +.sb-ch-menu { + visibility: hidden; background: none; border: none; color: var(--text-3); cursor: pointer; + font-size: 14px; padding: 0 4px; line-height: 1; border-radius: 3px; margin-left: auto; +} +.sb-ch-menu:hover { color: var(--text); background: var(--bg-hover); } +.sb-channel-item:hover .sb-ch-menu { visibility: visible; } +.sidebar.collapsed .sb-ch-menu { display: none; } + .sb-ch-icon { color: var(--text-3); flex-shrink: 0; display: flex; } .sb-ch-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sidebar.collapsed .sb-ch-name { display: none; } @@ -731,14 +739,15 @@ /* ── Chats section folder items ── */ .sb-folder-group { margin-bottom: 1px; } +.sb-folder-group.drag-over .sb-folder-header { background: var(--accent-dim); } .sb-folder-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sb-folder-menu { - display: none; background: none; border: none; cursor: pointer; + visibility: hidden; background: none; border: none; cursor: pointer; color: var(--text-3); font-size: 14px; padding: 0 2px; line-height: 1; flex-shrink: 0; border-radius: 3px; } .sb-folder-menu:hover { color: var(--text); background: var(--bg-hover); } -.sb-folder-header:hover .sb-folder-menu { display: block; } +.sb-folder-header:hover .sb-folder-menu { visibility: visible; } .sb-folder-empty { padding: 4px 14px 6px 28px; font-size: 11px; color: var(--text-3); font-style: italic; } .sb-folder-header { @@ -756,7 +765,13 @@ } .sb-folder-header:hover { background: var(--bg-hover); color: var(--text-2); } -.sb-folder-arrow { margin-left: auto; font-size: 9px; } +.sb-folder-arrow { font-size: 9px; flex-shrink: 0; width: 12px; text-align: center; } + +/* v0.23.2: Drop zone for chats section */ +#sbBodyChats.drag-over { outline: 2px dashed var(--accent); outline-offset: -2px; border-radius: 4px; } +.sb-unfiled-zone { min-height: 30px; border-radius: 4px; margin-top: 2px; } +.sb-unfiled-zone.drag-over { outline: 2px dashed var(--accent); outline-offset: -2px; } +.sb-unfiled-hint { padding: 6px 14px; font-size: 11px; color: var(--text-3); font-style: italic; } /* Indented chat items inside folders */ .chat-item.indented { padding-left: 28px; } diff --git a/src/js/admin-scaffold.js b/src/js/admin-scaffold.js index 65d9db9..075ffdc 100644 --- a/src/js/admin-scaffold.js +++ b/src/js/admin-scaffold.js @@ -220,6 +220,13 @@ SCAFFOLDING.stats = '
Loading...
'; + SCAFFOLDING.channels = + '
' + + '
Archived
' + + '
Retention Mode
' + + '
' + + '
Loading...
'; + // === Add button actions ============================================== @@ -272,7 +279,7 @@ users:'people', teams:'people', groups:'people', providers:'ai', models:'ai', personas:'ai', roles:'ai', knowledgeBases:'ai', memory:'ai', health:'routing', routing:'routing', capabilities:'routing', - settings:'system', storage:'system', extensions:'system', + settings:'system', storage:'system', extensions:'system', channels:'system', usage:'monitoring', audit:'monitoring', stats:'monitoring', }; var cat = catMap[section] || 'people'; @@ -280,7 +287,7 @@ people: [{id:'users',l:'Users'},{id:'teams',l:'Teams'},{id:'groups',l:'Groups'}], ai: [{id:'providers',l:'Providers'},{id:'models',l:'Models'},{id:'personas',l:'Personas'},{id:'roles',l:'Roles'},{id:'knowledgeBases',l:'Knowledge'},{id:'memory',l:'Memory'}], routing: [{id:'health',l:'Health'},{id:'routing',l:'Routing'},{id:'capabilities',l:'Capabilities'}], - system: [{id:'settings',l:'Settings'},{id:'storage',l:'Storage'},{id:'extensions',l:'Extensions'}], + system: [{id:'settings',l:'Settings'},{id:'storage',l:'Storage'},{id:'extensions',l:'Extensions'},{id:'channels',l:'Channels'}], monitoring: [{id:'usage',l:'Usage'},{id:'audit',l:'Audit'},{id:'stats',l:'Stats'}], }; diff --git a/src/js/api.js b/src/js/api.js index a8d82ce..f9050c1 100644 --- a/src/js/api.js +++ b/src/js/api.js @@ -187,6 +187,7 @@ const API = { updateChannel(id, updates) { return this._put(`/api/v1/channels/${id}`, updates); }, generateTitle(id) { return this._post(`/api/v1/channels/${id}/generate-title`, {}); }, deleteChannel(id) { return this._del(`/api/v1/channels/${id}`); }, + markRead(id) { return this._post(`/api/v1/channels/${id}/mark-read`, {}); }, // Channel models (v0.20.0 — multi-model @mention routing) listChannelModels(channelId) { return this._get(`/api/v1/channels/${channelId}/models`); }, diff --git a/src/js/app-state.js b/src/js/app-state.js index 416c90e..8f5e4f0 100644 --- a/src/js/app-state.js +++ b/src/js/app-state.js @@ -7,7 +7,6 @@ const App = { chats: [], - currentChatId: null, models: [], hiddenModels: new Set(), isGenerating: false, @@ -21,12 +20,32 @@ const App = { collapsedProjects: {}, activeProjectId: null, showArchivedProjects: false, + // v0.23.2: Unified active conversation + activeConversation: null, // { id, type } where type = direct|dm|group|channel // v0.23.1: Multi-user - channels: [], // DMs + named channels - currentChannelId: null, + channels: [], // DMs + named channels (sidebar display data) folders: [], // chat folders (user-scoped) collapsedFolders: {}, presence: {}, // { userId: 'online' | 'offline' } + users: [], // v0.23.2: [{id, username, displayName}] for @mention autocomplete + + /** Get active conversation ID (shorthand). */ + get activeId() { return this.activeConversation?.id || null; }, + + /** Get active conversation type. */ + get activeType() { return this.activeConversation?.type || null; }, + + /** Set the active conversation. Clears if id is null. */ + setActive(id, type) { + this.activeConversation = id ? { id, type: type || 'direct' } : null; + }, + + /** Find the active conversation's chat object (with cached messages). */ + getActiveChat() { + const id = this.activeConversation?.id; + if (!id) return null; + return this.chats.find(c => c.id === id) || null; + }, findModel(id) { if (!id) return null; diff --git a/src/js/app.js b/src/js/app.js index 7105191..8854738 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -93,6 +93,9 @@ async function startApp() { UI.updateCapabilityBadges(); } + // v0.23.2: Load user list for @mention autocomplete + await loadUsers(); + // Guard: if token was invalidated during startup (401 → refresh fail → clearTokens), // kick back to login instead of rendering a broken UI. if (!API.isAuthed) { @@ -103,6 +106,16 @@ async function startApp() { await initBanners(); initFiles(); + + // v0.23.2: Create primary ChatPane instance from server-rendered mount points + if (typeof ChatPane !== 'undefined') { + ChatPane.primary = ChatPane.create({ + id: 'main', + messagesEl: document.getElementById('chatMessages'), + inputEl: document.getElementById('chatInputBar'), + standalone: false, + }); + } if (typeof ToolsToggle !== 'undefined') ToolsToggle.init(); if (typeof REPL !== 'undefined') REPL.init(); // v0.21.3: admin-gated REPL tab if (typeof KnowledgeUI !== 'undefined') { @@ -115,16 +128,25 @@ async function startApp() { UI.restoreSidebarSections(); UI.updateModelSelector(); - // Restore last-active chat from sessionStorage + // Restore last-active conversation from sessionStorage try { - const savedChat = sessionStorage.getItem('cs-active-chat'); - if (savedChat && App.chats.some(c => c.id === savedChat)) { - selectChat(savedChat); + const saved = sessionStorage.getItem('cs-active-conversation'); + if (saved) { + const ac = JSON.parse(saved); + if (ac?.id) { + const inChats = App.chats.some(c => c.id === ac.id); + const inChannels = (App.channels || []).some(c => c.id === ac.id); + if (inChats) { + selectChat(ac.id); + } else if (inChannels) { + selectChannel(ac.id); + } + } } } catch (_) {} - // If no chat was restored, show the welcome/empty state - if (!App.currentChatId) { + // If no conversation was restored, show the welcome/empty state + if (!App.activeId) { UI.showEmptyState(); } @@ -234,7 +256,7 @@ async function handleLogout() { Events.clear(); await API.logout(); App.chats = []; - App.currentChatId = null; + App.setActive(null); location.reload(); } diff --git a/src/js/channel-models.js b/src/js/channel-models.js index 3c25e2b..a291f5a 100644 --- a/src/js/channel-models.js +++ b/src/js/channel-models.js @@ -206,22 +206,41 @@ const ChannelModels = { // Build match list from ALL enabled models + personas (not just roster) const allModels = (App.models || []).filter(m => !m.hidden); - const matches = allModels.filter(m => { - // Match against persona handle + const modelMatches = allModels.filter(m => { const handle = (m.personaHandle || '').toLowerCase().replace(/-/g, ' '); - // Match against model ID const modelId = (m.baseModelId || m.id || '').toLowerCase().replace(/-/g, ' '); - // Match against display name const name = (m.name || '').toLowerCase().replace(/-/g, ' '); const firstName = name.split(' ')[0] || ''; - if (partial.length === 0) return true; // show all on bare @ + if (partial.length === 0) return true; return handle.startsWith(partial) || modelId.startsWith(partial) || name.startsWith(partial) || firstName.startsWith(partial); }); + // v0.23.2: Include users in autocomplete (after personas, before models) + const userMatches = (App.users || []).filter(u => { + const uname = u.username.toLowerCase(); + const dname = (u.displayName || '').toLowerCase(); + if (partial.length === 0) return true; + return uname.startsWith(partial) || dname.startsWith(partial); + }).map(u => ({ + id: u.username, + name: u.displayName || u.username, + baseModelId: u.username, + isPersona: false, + isUser: true, + personaHandle: null, + personaAvatar: null, + provider: '', + })); + + // Resolution order: personas first, then users, then models + const personas = modelMatches.filter(m => m.isPersona); + const models = modelMatches.filter(m => !m.isPersona); + const matches = [...personas, ...userMatches, ...models]; + if (matches.length === 0) { this.hideAutocomplete(); return; } // Cap at 10 to keep dropdown manageable this.showAutocomplete(inputEl, atIdx, cursorPos, matches.slice(0, 10)); @@ -253,13 +272,17 @@ const ChannelModels = { ? `` : m.isPersona ? `` - : `🤖`; + : m.isUser + ? `👤` + : `🤖`; const handleText = m.isPersona && m.personaHandle ? `@${esc(m.personaHandle)}` - : `@${esc(m.baseModelId || m.id)}`; + : m.isUser + ? `@${esc(m.baseModelId)}` + : `@${esc(m.baseModelId || m.id)}`; - const providerHint = m.provider ? esc(m.provider) : ''; + const providerHint = m.isUser ? 'user' : (m.provider ? esc(m.provider) : ''); item.innerHTML = `${avatar}
${esc(m.name || m.id)}${handleText}
${providerHint}`; item.addEventListener('click', () => { diff --git a/src/js/chat.js b/src/js/chat.js index 93c1cee..cc8156d 100644 --- a/src/js/chat.js +++ b/src/js/chat.js @@ -83,6 +83,8 @@ const ChatInput = { updateInputTokens(); // @mention autocomplete (v0.20.0) if (typeof ChannelModels !== 'undefined') ChannelModels.onInput(ChatInput); + // v0.23.2: Typing indicator for multi-user channels + _emitTyping(); }, maxHeight: 200, }); @@ -108,15 +110,30 @@ const ChatInput = { updateInputTokens(); // @mention autocomplete (v0.20.0) if (typeof ChannelModels !== 'undefined') ChannelModels.onInput(this); + // v0.23.2: Typing indicator + _emitTyping(); }); } }, }; +// v0.23.2: Debounced typing indicator for multi-user channels +let _typingTimer = null; +function _emitTyping() { + if (!App.activeId) return; + // Only emit for multi-user conversation types + const type = App.activeConversation?.type; + if (type !== 'dm' && type !== 'channel' && type !== 'group') return; + // Debounce: emit at most once every 3s + if (_typingTimer) return; + _typingTimer = setTimeout(() => { _typingTimer = null; }, 3000); + API._post(`/api/v1/channels/${App.activeId}/typing`, {}).catch(() => {}); +} + // ── Summarize & Continue ────────────────── async function summarizeAndContinue() { - const channelId = App.currentChatId; + const channelId = App.activeId; if (!channelId) return; const btn = document.getElementById('summarizeBtn'); @@ -145,6 +162,10 @@ async function summarizeAndContinue() { metadata: m.metadata || null, siblingCount: m.sibling_count || 1, siblingIndex: m.sibling_index || 0, + participant_type: m.participant_type || null, + participant_id: m.participant_id || null, + sender_name: m.sender_name || null, + sender_avatar: m.sender_avatar || null, })); UI.renderMessages(chat.messages); updateContextWarning(); updateChatTokenCount(); @@ -164,7 +185,7 @@ async function summarizeAndContinue() { // ── Per-Channel Auto-Compaction Toggle ────── async function toggleChannelAutoCompact(enabled) { - const chatId = App.currentChatId; + const chatId = App.activeId; if (!chatId) return; const chat = App.chats.find(c => c.id === chatId); if (!chat) return; @@ -193,6 +214,7 @@ async function loadChats() { model: c.model || '', messageCount: c.message_count || 0, projectId: c.project_id || null, + folderId: c.folder || null, workspace_id: c.workspace_id || null, messages: [], updatedAt: c.updated_at, @@ -206,16 +228,17 @@ async function loadChats() { async function selectChat(chatId) { clearStaged(); // Discard any staged files from previous chat - App.currentChatId = chatId; - try { sessionStorage.setItem('cs-active-chat', chatId); } catch (_) {} + const chat = App.chats.find(c => c.id === chatId); + App.setActive(chatId, chat?.type || 'direct'); + try { sessionStorage.setItem('cs-active-conversation', JSON.stringify(App.activeConversation)); } catch (_) {} UI.renderChatList(); + UI.renderChannelsSection(); if (window.innerWidth <= 768) { document.getElementById('sidebar').classList.add('collapsed'); const ov = document.getElementById('sidebarOverlay'); if (ov) ov.style.display = 'none'; } - const chat = App.chats.find(c => c.id === chatId); if (!chat) return; if (chat.messages.length === 0 && chat.messageCount > 0) { @@ -233,6 +256,10 @@ async function selectChat(chatId) { metadata: m.metadata || null, siblingCount: m.sibling_count || 1, siblingIndex: m.sibling_index || 0, + participant_type: m.participant_type || null, + participant_id: m.participant_id || null, + sender_name: m.sender_name || null, + sender_avatar: m.sender_avatar || null, })); } catch (e) { console.error('Failed to load messages:', e.message); @@ -250,6 +277,7 @@ async function selectChat(chatId) { await loadChannelFiles(chatId); UI.renderMessages(chat.messages); + UI.updateContextBanner(); UI.showRegenerate(chat.messages.some(m => m.role === 'assistant')); Tokens._warningDismissed = false; updateContextWarning(); updateChatTokenCount(); @@ -264,6 +292,9 @@ async function selectChat(chatId) { // Update browser URL to reflect selected chat window.history.replaceState(null, '', `${window.__BASE__ || ''}/chat/${chatId}`); + + // v0.23.2: Mark as read (non-blocking) + API.markRead(chatId).catch(() => {}); } // ── Chat Header Token Count ────────────────── @@ -271,7 +302,7 @@ async function selectChat(chatId) { function updateChatTokenCount() { const el = document.getElementById('chatTokenCount'); if (!el) return; - const chat = App.chats.find(c => c.id === App.currentChatId); + const chat = App.getActiveChat(); if (!chat || !chat.messages.length) { el.textContent = ''; return; } const tokens = Tokens.estimateConversation(chat.messages, App.settings?.systemPrompt); @@ -379,8 +410,9 @@ function _cleanStaleChatModel(chatId) { async function newChat() { clearStaged(); // Discard any staged files - App.currentChatId = null; + App.setActive(null); UI.renderChatList(); + UI.renderChannelsSection(); UI.showEmptyState(); UI.showRegenerate(false); Tokens._warningDismissed = false; @@ -492,7 +524,7 @@ async function newGroupChat() { } App.chats.unshift(chat); - App.currentChatId = chat.id; + App.setActive(chat.id, 'group'); UI.renderChatList(); Events.emit('chat.created', { chatId: chat.id, projectId: null }, { localOnly: true }); window.history.replaceState(null, '', `${window.__BASE__ || ''}/chat/${chat.id}`); @@ -526,7 +558,7 @@ async function deleteChat(chatId) { delete map[chatId]; localStorage.setItem('cs-chat-models', JSON.stringify(map)); } catch (e) { /* */ } - if (App.currentChatId === chatId) { clearPreview(); newChat(); } + if (App.activeId === chatId) { clearPreview(); newChat(); } UI.renderChatList(); } catch (e) { UI.toast('Failed to delete: ' + e.message, 'error'); } } @@ -634,7 +666,7 @@ async function sendMessage() { const model = modelInfo?.baseModelId || selectedId; const personaId = modelInfo?.isPersona ? modelInfo.personaId : null; - if (!App.currentChatId) { + if (!App.activeId) { try { const title = text.slice(0, 50) + (text.length > 50 ? '...' : ''); const resp = await API.createChannel(title, model, App.settings.systemPrompt, 'direct'); @@ -647,7 +679,7 @@ async function sendMessage() { } catch (_) { /* best effort — chat still works without project */ } } App.chats.unshift(chat); - App.currentChatId = chat.id; + App.setActive(chat.id, 'direct'); UI.renderChatList(); // Notify surfaces about new chat creation (v0.21.6) Events.emit('chat.created', { chatId: chat.id, projectId: chat.projectId || null }, { localOnly: true }); @@ -655,7 +687,7 @@ async function sendMessage() { } catch (e) { UI.toast('Failed to create chat: ' + e.message, 'error'); return; } } - const chat = App.chats.find(c => c.id === App.currentChatId); + const chat = App.getActiveChat(); if (!chat) return; // Optimistic: show user message immediately @@ -671,8 +703,20 @@ async function sendMessage() { UI.setGenerating(true); try { - const resp = await API.streamCompletion(App.currentChatId, text, model, App.abortController.signal, modelInfo?.configId, personaId, fileIds, + const resp = await API.streamCompletion(App.activeId, text, model, App.abortController.signal, modelInfo?.configId, personaId, fileIds, typeof ToolsToggle !== 'undefined' ? ToolsToggle.getDisabledTools() : []); + + // v0.23.2: Non-streaming response (DM with mention_only, no @mention) + const ct = (resp.headers.get('content-type') || '').toLowerCase(); + if (ct.includes('application/json')) { + const data = await resp.json(); + if (data.ai_skipped) { + // Message was persisted server-side; reload to get proper IDs + await reloadActivePath(); + return; + } + } + await UI.streamResponse(resp, chat.messages, modelInfo?.name); // Reload active path from server to get proper IDs and sibling metadata @@ -680,11 +724,11 @@ async function sendMessage() { UI.showRegenerate(true); // Persist the model/persona selection for this chat - _saveChatModel(App.currentChatId); + _saveChatModel(App.activeId); // Auto-name: title the chat after first assistant response (v0.17.0) if (chat.messages.length <= 3) { - autoNameChat(App.currentChatId, chat); + autoNameChat(App.activeId, chat); } } catch (e) { if (e.name === 'AbortError') { @@ -717,12 +761,12 @@ function stopGeneration() { if (App.abortController) App.abortController.abort() // ── Reload Active Path from Server ────────── async function reloadActivePath() { - if (!App.currentChatId) return; - const chat = App.chats.find(c => c.id === App.currentChatId); + if (!App.activeId) return; + const chat = App.getActiveChat(); if (!chat) return; try { - const resp = await API.getActivePath(App.currentChatId); + const resp = await API.getActivePath(App.activeId); chat.messages = (resp.path || []).map(m => ({ id: m.id, parent_id: m.parent_id || null, @@ -735,9 +779,13 @@ async function reloadActivePath() { metadata: m.metadata || null, siblingCount: m.sibling_count || 1, siblingIndex: m.sibling_index || 0, + participant_type: m.participant_type || null, + participant_id: m.participant_id || null, + sender_name: m.sender_name || null, + sender_avatar: m.sender_avatar || null, })); chat.messageCount = chat.messages.length; - await loadChannelFiles(App.currentChatId); + await loadChannelFiles(App.activeId); UI.renderMessages(chat.messages); updateContextWarning(); updateChatTokenCount(); } catch (e) { @@ -748,7 +796,7 @@ async function reloadActivePath() { // ── Regenerate (tree-aware: creates sibling) ─ async function regenerateMessage(messageId) { - if (App.isGenerating || !App.currentChatId) return; + if (App.isGenerating || !App.activeId) return; const selectedId = UI.getModelValue(); const modelInfo = App.findModel(selectedId); @@ -758,7 +806,7 @@ async function regenerateMessage(messageId) { // Truncate display to the parent of the message being regenerated. // This makes the stream appear in the correct position — as a fresh // response to the parent user message, not appended at the bottom. - const chat = App.chats.find(c => c.id === App.currentChatId); + const chat = App.getActiveChat(); if (chat) { const msgIdx = chat.messages.findIndex(m => m.id === messageId); if (msgIdx > 0) { @@ -773,7 +821,7 @@ async function regenerateMessage(messageId) { try { const resp = await API.streamRegenerate( - App.currentChatId, messageId, App.abortController.signal, + App.activeId, messageId, App.abortController.signal, model, personaId, modelInfo?.configId, typeof ToolsToggle !== 'undefined' ? ToolsToggle.getDisabledTools() : [] ); @@ -803,8 +851,8 @@ async function regenerateMessage(messageId) { // Legacy: bottom-bar regenerate button targets the last assistant message async function regenerate() { - if (App.isGenerating || !App.currentChatId) return; - const chat = App.chats.find(c => c.id === App.currentChatId); + if (App.isGenerating || !App.activeId) return; + const chat = App.getActiveChat(); if (!chat || chat.messages.length === 0) return; // Find the last assistant message with an ID @@ -819,9 +867,9 @@ async function regenerate() { // ── Edit Message (creates sibling, triggers completion) ── async function editMessage(messageId) { - if (App.isGenerating || !App.currentChatId) return; + if (App.isGenerating || !App.activeId) return; - const chat = App.chats.find(c => c.id === App.currentChatId); + const chat = App.getActiveChat(); if (!chat) return; const msg = chat.messages.find(m => m.id === messageId); @@ -832,7 +880,7 @@ async function editMessage(messageId) { } async function submitEdit(messageId, newContent) { - if (App.isGenerating || !App.currentChatId) return; + if (App.isGenerating || !App.activeId) return; if (!newContent.trim()) return; const selectedId = UI.getModelValue(); @@ -845,10 +893,10 @@ async function submitEdit(messageId, newContent) { UI.setGenerating(true); try { - const chat = App.chats.find(c => c.id === App.currentChatId); + const chat = App.getActiveChat(); // Step 1: Create sibling user message via edit endpoint - const editResp = await API.editMessage(App.currentChatId, messageId, newContent.trim()); + const editResp = await API.editMessage(App.activeId, messageId, newContent.trim()); // Step 2: Reload path to show the new edited message await reloadActivePath(); @@ -858,7 +906,7 @@ async function submitEdit(messageId, newContent) { // a child response, not a sibling). This avoids the duplicate user // message that streamCompletion would create. const resp = await API.streamRegenerate( - App.currentChatId, editResp.id, App.abortController.signal, + App.activeId, editResp.id, App.abortController.signal, model, personaId, modelInfo?.configId, typeof ToolsToggle !== 'undefined' ? ToolsToggle.getDisabledTools() : [] ); @@ -885,18 +933,18 @@ async function submitEdit(messageId, newContent) { function cancelEdit() { // Re-render to remove the edit form - const chat = App.chats.find(c => c.id === App.currentChatId); + const chat = App.getActiveChat(); if (chat) UI.renderMessages(chat.messages); } // ── Switch Branch (sibling navigation) ────── async function switchSibling(messageId, direction) { - if (App.isGenerating || !App.currentChatId) return; + if (App.isGenerating || !App.activeId) return; try { // Get siblings for this message - const data = await API.listSiblings(App.currentChatId, messageId); + const data = await API.listSiblings(App.activeId, messageId); const siblings = data.siblings || []; const currentIdx = data.current_index ?? 0; @@ -906,10 +954,10 @@ async function switchSibling(messageId, direction) { const targetSibling = siblings[targetIdx]; // Update cursor to the target sibling (backend walks to leaf) - const resp = await API.updateCursor(App.currentChatId, targetSibling.id); + const resp = await API.updateCursor(App.activeId, targetSibling.id); // Update local state with the new path - const chat = App.chats.find(c => c.id === App.currentChatId); + const chat = App.getActiveChat(); if (chat && resp.path) { chat.messages = resp.path.map(m => ({ id: m.id, @@ -923,6 +971,10 @@ async function switchSibling(messageId, direction) { metadata: m.metadata || null, siblingCount: m.sibling_count || 1, siblingIndex: m.sibling_index || 0, + participant_type: m.participant_type || null, + participant_id: m.participant_id || null, + sender_name: m.sender_name || null, + sender_avatar: m.sender_avatar || null, })); chat.messageCount = chat.messages.length; UI.renderMessages(chat.messages); @@ -997,7 +1049,26 @@ function _initChatListeners() { // runs server-side and delivers the result via WS, not SSE. Events.on('message.created', (payload) => { if (!payload || !payload.channel_id) return; - if (payload.channel_id !== App.currentChatId) return; + + // v0.23.2: If this channel isn't in our sidebar yet, fetch and add it + const knownChannel = (App.channels || []).some(c => c.id === payload.channel_id); + const knownChat = App.chats.some(c => c.id === payload.channel_id); + if (!knownChannel && !knownChat) { + // New channel we're a participant in — reload channels sidebar + if (typeof loadChannels === 'function') loadChannels(); + UI.toast(`New message from ${payload.display_name || payload.user_id?.slice(0, 8) || 'someone'}`, 'info'); + return; + } + + // If it's a channel/DM we know but it's not the active one, bump unread + if (payload.channel_id !== App.activeId) { + const ch = (App.channels || []).find(c => c.id === payload.channel_id); + if (ch) { + ch.unread = (ch.unread || 0) + 1; + if (typeof UI !== 'undefined') UI.renderChannelsSection(); + } + return; + } const chat = App.chats.find(c => c.id === payload.channel_id); if (!chat) return; @@ -1021,16 +1092,18 @@ function _initChatListeners() { // Remove typing indicator and re-render document.getElementById('typingIndicator')?.remove(); + document.getElementById('userTypingIndicator')?.remove(); UI.renderMessages(chat.messages); }); // Typing indicators from persona chain activity Events.on('typing.start', (payload) => { - if (!payload || payload.channel_id !== App.currentChatId) return; + if (!payload || payload.channel_id !== App.activeId) return; if (payload.participant_type !== 'persona') return; // Show typing indicator with persona name document.getElementById('typingIndicator')?.remove(); + document.getElementById('userTypingIndicator')?.remove(); const container = document.getElementById('chatMessages'); if (!container) return; const typing = document.createElement('div'); @@ -1047,8 +1120,63 @@ function _initChatListeners() { }); Events.on('typing.stop', (payload) => { - if (!payload || payload.channel_id !== App.currentChatId) return; + if (!payload || payload.channel_id !== App.activeId) return; document.getElementById('typingIndicator')?.remove(); + document.getElementById('userTypingIndicator')?.remove(); + }); + + // v0.23.2: Human typing indicator from other participants + Events.on('typing.user', (payload) => { + if (!payload || payload.channel_id !== App.activeId) return; + if (payload.user_id === API.user?.id) return; // ignore self + + // Show or refresh typing indicator + let typing = document.getElementById('userTypingIndicator'); + if (!typing) { + const container = document.getElementById('chatMessages'); + if (!container) return; + typing = document.createElement('div'); + typing.id = 'userTypingIndicator'; + typing.className = 'message other-user'; + container.appendChild(typing); + } + const name = payload.display_name || 'Someone'; + typing.innerHTML = ` +
+
👤
+
+ ${esc(name)} +
+
+
`; + const container = document.getElementById('chatMessages'); + if (container) container.scrollTop = container.scrollHeight; + + // Auto-remove after 4s if no new event + clearTimeout(typing._timeout); + typing._timeout = setTimeout(() => typing.remove(), 4000); + }); + + // v0.23.2: User @mention notification + Events.on('user.mentioned', (payload) => { + if (!payload) return; + const channelId = payload.channel_id; + // Increment unread badge on the channel sidebar item + const ch = (App.channels || []).find(c => c.id === channelId); + if (ch) { + ch.unread = (ch.unread || 0) + 1; + if (typeof UI !== 'undefined') UI.renderChannelsSection(); + } + // Toast with deep-link + const preview = payload.content || 'You were mentioned'; + UI.toast(`@mention: ${preview}`, 'info'); + }); + + // v0.23.2: Live presence updates for sidebar online dots + Events.on('user.presence', (payload) => { + if (!payload || !payload.user_id) return; + App.presence[payload.user_id] = payload.status || 'offline'; + if (typeof UI !== 'undefined') UI.renderChannelsSection(); }); // Close modals on overlay click diff --git a/src/js/debug.js b/src/js/debug.js index 245419b..cdd6fe1 100644 --- a/src/js/debug.js +++ b/src/js/debug.js @@ -238,7 +238,7 @@ const DebugLog = { if (typeof App !== 'undefined') { snap.app = { chatCount: App.chats?.length || 0, - currentChatId: App.currentChatId, + activeConversation: App.activeConversation, modelCount: App.models?.length || 0, isGenerating: App.isGenerating, settings: { diff --git a/src/js/files.js b/src/js/files.js index 3d57286..7027b01 100644 --- a/src/js/files.js +++ b/src/js/files.js @@ -81,7 +81,7 @@ async function stageFile(file) { _updateSendBlock(); // Ensure a channel exists (files are channel-scoped) - if (!App.currentChatId) { + if (!App.activeId) { try { const title = file.name.slice(0, 50); const selectedId = UI.getModelValue(); @@ -93,7 +93,7 @@ async function stageFile(file) { model, messages: [], messageCount: 0, updatedAt: resp.updated_at, }; App.chats.unshift(chat); - App.currentChatId = chat.id; + App.setActive(chat.id, 'direct'); UI.renderChatList(); } catch (e) { staged.status = 'error'; @@ -106,7 +106,7 @@ async function stageFile(file) { // Upload try { - const result = await API.uploadFile(App.currentChatId, file); + const result = await API.uploadFile(App.activeId, file); staged.serverId = result.id; staged.status = _mapExtractionStatus(result.metadata?.extraction_status); staged.extractionStatus = result.metadata?.extraction_status || null; diff --git a/src/js/knowledge-ui.js b/src/js/knowledge-ui.js index 10c406b..fcd2c63 100644 --- a/src/js/knowledge-ui.js +++ b/src/js/knowledge-ui.js @@ -28,7 +28,7 @@ const KnowledgeUI = (() => { const popup = document.getElementById('kbPopup'); if (!popup) return; - if (!App.currentChatId) { + if (!App.activeId) { popup.innerHTML = '
Start a conversation first
'; popup.classList.add('open'); return; @@ -40,7 +40,7 @@ const KnowledgeUI = (() => { try { const [allResp, chResp] = await Promise.all([ API.listKnowledgeBases(), - API.getChannelKBs(App.currentChatId), + API.getChannelKBs(App.activeId), ]); _allKBs = allResp.data || []; _channelKBs = chResp.data || []; @@ -82,7 +82,7 @@ const KnowledgeUI = (() => { } async function _toggleChannelKB(kbId) { - if (!App.currentChatId) return; + if (!App.activeId) return; const linkedSet = new Set(_channelKBs.map(kb => kb.kb_id)); if (linkedSet.has(kbId)) { @@ -92,7 +92,7 @@ const KnowledgeUI = (() => { } try { - const resp = await API.setChannelKBs(App.currentChatId, [...linkedSet]); + const resp = await API.setChannelKBs(App.activeId, [...linkedSet]); _channelKBs = resp.data || []; const popup = document.getElementById('kbPopup'); if (popup) _renderChannelPopup(popup); @@ -506,9 +506,9 @@ const KnowledgeUI = (() => { /** Refresh channel KB state when switching chats. */ async function onChatChanged() { _channelKBs = []; - if (App.currentChatId) { + if (App.activeId) { try { - const resp = await API.getChannelKBs(App.currentChatId); + const resp = await API.getChannelKBs(App.activeId); _channelKBs = resp.data || []; } catch { /* silent */ } } diff --git a/src/js/notes.js b/src/js/notes.js index 7ab0eed..f248e9b 100644 --- a/src/js/notes.js +++ b/src/js/notes.js @@ -596,7 +596,7 @@ async function _navigateDaily(offset) { // ── Save-to-Note (from chat messages) ── async function saveMessageToNote(msgIndex) { - const chat = App.chats?.find(c => c.id === App.currentChatId); + const chat = App.chats?.find(c => c.id === App.activeId); const msg = chat?.messages?.[msgIndex]; if (!msg) return; @@ -617,7 +617,7 @@ async function saveMessageToNote(msgIndex) { // Show save-to-note modal _showSaveToNoteModal({ content, - sourceChannelId: App.currentChatId || '', + sourceChannelId: App.activeId || '', sourceMessageId: msg.id || '', defaultTitle, }); diff --git a/src/js/projects-ui.js b/src/js/projects-ui.js index 44b8358..1c831fd 100644 --- a/src/js/projects-ui.js +++ b/src/js/projects-ui.js @@ -299,6 +299,23 @@ function showChatContextMenu(e, chatId) { 📁 Set workspace… `; + // v0.23.2: Folder options + const folders = App.folders || []; + if (folders.length > 0 || chat.folderId) { + items += '
'; + if (chat.folderId) { + items += ``; + } + folders.forEach(f => { + if (f.id === chat.folderId) return; // skip current + items += ``; + }); + } + menu.innerHTML = items; // Position near cursor @@ -341,7 +358,8 @@ function onChatDragStart(e, chatId) { function onChatDragEnd(e) { e.target.classList.remove('dragging'); - document.querySelectorAll('.project-group.drag-over').forEach(el => el.classList.remove('drag-over')); + // Clear all drag-over highlights (projects, folders, section bodies, unfiled zone) + document.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over')); } function onProjectDragOver(e) { @@ -370,6 +388,64 @@ async function onRecentDrop(e) { await moveChatToProject(chatId, null); } +// v0.23.2: Folder drag-and-drop + +async function onFolderDrop(e, folderId) { + e.preventDefault(); + e.stopPropagation(); + e.currentTarget.classList.remove('drag-over'); + const chatId = e.dataTransfer.getData('text/plain'); + if (!chatId) return; + await moveChatToFolder(chatId, folderId); +} + +async function onChatSectionDrop(e) { + e.preventDefault(); + e.currentTarget.classList.remove('drag-over'); + const chatId = e.dataTransfer.getData('text/plain'); + if (!chatId) return; + const chat = App.chats.find(c => c.id === chatId); + if (!chat) return; + + // Unfile from folder if filed + if (chat.folderId) { + await moveChatToFolder(chatId, null); + } + // Remove from project if in one + if (chat.projectId) { + await moveChatToProject(chatId, null); + } +} + +async function moveChatToFolder(chatId, folderId) { + try { + await API.updateChannel(chatId, { folder: folderId || '' }); + const chat = App.chats.find(c => c.id === chatId); + if (chat) chat.folderId = folderId || null; + UI.renderChatList(); + } catch (e) { + UI.toast('Failed to move chat: ' + e.message, 'error'); + } +} + +async function onUnfiledDrop(e) { + e.preventDefault(); + e.stopPropagation(); + e.currentTarget.classList.remove('drag-over'); + const chatId = e.dataTransfer.getData('text/plain'); + if (!chatId) return; + const chat = App.chats.find(c => c.id === chatId); + if (!chat) return; + // Unfile from folder + if (chat.folderId) { + await moveChatToFolder(chatId, null); + } + // Remove from project + if (chat.projectId) { + await moveChatToProject(chatId, null); + } +} + // ── Project Header Menu ───────────────────── function showProjectMenu(e, projectId) { @@ -1106,7 +1182,19 @@ async function loadFolders() { async function loadChannels() { try { const resp = await API.listSidebarChannels(); - App.channels = (resp.channels || []).map(normalizeChannel); + App.channels = (resp.data || []).map(normalizeChannel); + + // Query presence for DM partners + const dmPartnerIds = App.channels + .filter(c => c.type === 'dm' && c.dmPartnerId) + .map(c => c.dmPartnerId); + if (dmPartnerIds.length > 0) { + try { + const presResp = await API._get(`/api/v1/presence?users=${dmPartnerIds.join(',')}`); + App.presence = presResp.presence || {}; + } catch (_) { /* non-critical */ } + } + if (typeof UI !== 'undefined') UI.renderChannelsSection(); } catch (e) { console.warn('[channels] load failed:', e.message); @@ -1127,27 +1215,117 @@ function normalizeChannel(ch) { }; } +// v0.23.2: Load user list for @mention autocomplete and DM creation +async function loadUsers() { + try { + const resp = await API._get('/api/v1/users/search?q='); + App.users = (resp.users || []).map(u => ({ + id: u.id, + username: u.username, + displayName: u.display_name || u.username, + })); + } catch (e) { + console.warn('[users] load failed:', e.message); + App.users = []; + } +} + async function selectChannel(channelId) { - App.currentChannelId = channelId; - App.currentChatId = null; // clear chat selection + const ch = (App.channels || []).find(c => c.id === channelId); + App.setActive(channelId, ch?.type || 'channel'); if (typeof UI !== 'undefined') { UI.renderChannelsSection(); UI.renderChatList(); } + // Ensure conversation object exists in App.chats for message caching + if (!App.chats.find(c => c.id === channelId)) { + App.chats.unshift({ + id: channelId, title: ch?.name || 'Channel', type: ch?.type || 'channel', + model: '', messages: [], messageCount: 0, projectId: null, + workspace_id: null, updatedAt: ch?.updatedAt || new Date().toISOString(), + settings: {}, + }); + } // Load messages for this channel try { + const chat = App.chats.find(c => c.id === channelId); const resp = await API._get(`/api/v1/channels/${channelId}/messages`); - const messages = resp.messages || []; - if (typeof UI !== 'undefined') UI.renderMessages(messages); + const messages = (resp.data || []).map(m => ({ + id: m.id, + parent_id: m.parent_id || null, + role: m.role, + content: m.content, + model: m.model || '', + modelName: '', + timestamp: m.created_at, + tool_calls: m.tool_calls || null, + metadata: m.metadata || null, + siblingCount: m.sibling_count || 1, + siblingIndex: m.sibling_index || 0, + participant_type: m.participant_type || null, + participant_id: m.participant_id || null, + sender_name: m.sender_name || null, + sender_avatar: m.sender_avatar || null, + })); + if (chat) chat.messages = messages; + if (typeof UI !== 'undefined') { + UI.renderMessages(messages); + UI.updateContextBanner(); + } } catch (e) { console.warn('[channel] message load failed:', e.message); } try { - sessionStorage.setItem('cs-active-channel', channelId); + sessionStorage.setItem('cs-active-conversation', JSON.stringify(App.activeConversation)); } catch (_) {} + + // v0.23.2: Mark as read and clear unread badge + const selCh = (App.channels || []).find(c => c.id === channelId); + if (selCh) selCh.unread = 0; + API.markRead(channelId).catch(() => {}); } async function newChannelOrDM() { + // Show choice: Channel or DM + const choice = await new Promise(resolve => { + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay active'; + overlay.innerHTML = ` + `; + overlay.addEventListener('click', e => { if (e.target === overlay) { overlay.remove(); resolve(null); } }); + overlay.querySelector('#_ncChannel').onclick = () => { overlay.remove(); resolve('channel'); }; + overlay.querySelector('#_ncDM').onclick = () => { overlay.remove(); resolve('dm'); }; + overlay.querySelector('#_ncGroup').onclick = () => { overlay.remove(); resolve('group'); }; + document.body.appendChild(overlay); + }); + + if (choice === 'channel') { + await _createNewChannel(); + } else if (choice === 'dm') { + await _createNewDM(); + } else if (choice === 'group') { + if (typeof newGroupChat === 'function') { + await newGroupChat(); + } else { + UI.toast('Group chat not available', 'warning'); + } + } +} + +async function _createNewChannel() { const result = await _showCreationDialog({ title: 'New Channel', placeholder: 'Channel name', @@ -1159,11 +1337,139 @@ async function newChannelOrDM() { const ch = normalizeChannel(raw); App.channels = App.channels || []; App.channels.push(ch); - if (typeof UI !== 'undefined') UI.renderChannelsSection(); + // Also add to App.chats for message caching / send path + App.chats.unshift({ + id: raw.id, title: raw.title || result.name, type: 'channel', + model: '', messages: [], messageCount: 0, projectId: raw.project_id || null, + workspace_id: raw.workspace_id || null, updatedAt: raw.updated_at, + settings: raw.settings || {}, + }); + App.setActive(raw.id, 'channel'); + if (typeof UI !== 'undefined') { + UI.renderChannelsSection(); + UI.renderChatList(); + UI.updateContextBanner(); + } UI.toast(`#${result.name} created`, 'success'); } catch (e) { UI.toast('Failed to create channel', 'error'); - console.error('[newChannelOrDM]', e); + console.error('[newChannel]', e); + } +} + +async function _createNewDM() { + return new Promise(resolve => { + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay active'; + overlay.innerHTML = ` + `; + + const input = overlay.querySelector('#_dmSearchInput'); + const results = overlay.querySelector('#_dmResults'); + let debounce = null; + let selectedUser = null; + + const close = () => { overlay.remove(); resolve(); }; + overlay.addEventListener('click', e => { if (e.target === overlay) close(); }); + overlay.querySelector('#_dmCancel').onclick = close; + + async function search(q) { + try { + const resp = await API._get(`/api/v1/users/search?q=${encodeURIComponent(q)}`); + const users = resp.users || []; + if (users.length === 0) { + results.innerHTML = '
No users found
'; + return; + } + results.innerHTML = users.map(u => ` +
+ + + + + ${esc(u.display_name || u.username)} + ${u.display_name ? `@${esc(u.username)}` : ''} + +
+ `).join(''); + + results.querySelectorAll('.dm-user-item').forEach(el => { + el.onmouseenter = () => el.style.background = 'var(--bg-hover)'; + el.onmouseleave = () => el.style.background = ''; + el.onclick = async () => { + const userId = el.dataset.id; + const userName = el.dataset.name; + overlay.remove(); + await _finishDMCreation(userId, userName); + resolve(); + }; + }); + } catch (e) { + results.innerHTML = `
Search failed: ${esc(e.message)}
`; + } + } + + input.addEventListener('input', () => { + clearTimeout(debounce); + const q = input.value.trim(); + if (q.length === 0) { + // Show all users when empty + debounce = setTimeout(() => search(''), 100); + return; + } + debounce = setTimeout(() => search(q), 200); + }); + + document.body.appendChild(overlay); + input.focus(); + // Show all users immediately + search(''); + }); +} + +async function _finishDMCreation(targetId, targetName) { + try { + const raw = await API._post('/api/v1/channels', { + title: targetName, + type: 'dm', + participants: [targetId], + }); + const ch = normalizeChannel(raw); + // Avoid duplicate in sidebar list (dedup guard may return existing) + if (!(App.channels || []).find(c => c.id === ch.id)) { + App.channels = App.channels || []; + App.channels.push(ch); + } + if (!App.chats.find(c => c.id === raw.id)) { + App.chats.unshift({ + id: raw.id, title: raw.title || targetName, + type: 'dm', model: '', messages: [], messageCount: 0, + projectId: null, workspace_id: null, updatedAt: raw.updated_at, + settings: raw.settings || {}, + }); + } + App.setActive(raw.id, 'dm'); + if (typeof UI !== 'undefined') { + UI.renderChannelsSection(); + UI.renderChatList(); + UI.updateContextBanner(); + } + UI.toast(`DM with ${targetName}`, 'success'); + } catch (e) { + UI.toast('Failed to create DM: ' + e.message, 'error'); + console.error('[newDM]', e); } } @@ -1191,6 +1497,162 @@ async function newFolder() { } } +// ── Channel CRUD (v0.23.2) ───────────────────── + +function showChannelContextMenu(e, channelId) { + e.preventDefault(); + e.stopPropagation(); + dismissChatContextMenu(); + + const ch = (App.channels || []).find(c => c.id === channelId); + if (!ch) return; + + const menu = document.createElement('div'); + menu.className = 'project-ctx-menu'; + + let items = ''; + items += ``; + items += ``; + if (ch.type === 'channel' || ch.type === 'group') { + items += ``; + items += ``; + } + items += '
'; + items += ``; + // Delete gated by retention mode (default=flexible allows delete) + const retentionMode = App.policies?.channel_retention_mode || 'flexible'; + if (retentionMode === 'flexible') { + items += ``; + } + + menu.innerHTML = items; + menu.style.left = Math.min(e.clientX, window.innerWidth - 180) + 'px'; + menu.style.top = Math.min(e.clientY, window.innerHeight - 150) + 'px'; + + document.body.appendChild(menu); + _ctxMenu = menu; + setTimeout(() => document.addEventListener('click', dismissChatContextMenu, { once: true }), 0); +} + +async function renameChannel(channelId) { + const ch = (App.channels || []).find(c => c.id === channelId); + if (!ch) return; + const result = await _showCreationDialog({ + title: 'Rename Channel', + placeholder: 'Channel name', + withColor: false, + defaultValue: ch.name, + confirmLabel: 'Rename', + }); + if (!result) return; + try { + await API.updateChannel(channelId, { title: result.name.trim() }); + ch.name = result.name.trim(); + // Also update in App.chats if present + const chat = App.chats.find(c => c.id === channelId); + if (chat) chat.title = result.name.trim(); + if (typeof UI !== 'undefined') UI.renderChannelsSection(); + UI.toast('Channel renamed', 'success'); + } catch (e) { + UI.toast('Failed to rename channel', 'error'); + } +} + +async function editChannelTopic(channelId) { + const ch = (App.channels || []).find(c => c.id === channelId); + if (!ch) return; + const result = await _showCreationDialog({ + title: 'Set Topic', + placeholder: 'Channel topic (optional)', + withColor: false, + defaultValue: ch.topic || '', + confirmLabel: 'Save', + }); + if (!result) return; + try { + // Topic is now a proper field on updateChannelRequest (v0.23.2) + await API._put(`/api/v1/channels/${channelId}`, { topic: result.name.trim() }); + ch.topic = result.name.trim(); + if (typeof UI !== 'undefined') UI.updateContextBanner(); + UI.toast('Topic updated', 'success'); + } catch (e) { + UI.toast('Failed to set topic', 'error'); + } +} + +async function deleteChannel(channelId) { + if (!await showConfirm('Delete this channel and all its messages?')) return; + try { + await API.deleteChannel(channelId); + App.channels = (App.channels || []).filter(c => c.id !== channelId); + App.chats = App.chats.filter(c => c.id !== channelId); + if (App.activeId === channelId) { + App.setActive(null); + UI.showEmptyState(); + } + if (typeof UI !== 'undefined') { + UI.renderChannelsSection(); + UI.renderChatList(); + UI.updateContextBanner(); + } + UI.toast('Channel deleted', 'success'); + } catch (e) { + UI.toast('Failed to delete: ' + e.message, 'error'); + } +} + +async function archiveChannel(channelId) { + try { + await API.updateChannel(channelId, { is_archived: true }); + // Remove from sidebar (archived channels are hidden) + App.channels = (App.channels || []).filter(c => c.id !== channelId); + App.chats = App.chats.filter(c => c.id !== channelId); + if (App.activeId === channelId) { + App.setActive(null); + UI.showEmptyState(); + } + if (typeof UI !== 'undefined') { + UI.renderChannelsSection(); + UI.renderChatList(); + UI.updateContextBanner(); + } + UI.toast('Channel archived', 'success'); + } catch (e) { + UI.toast('Failed to archive: ' + e.message, 'error'); + } +} + +// v0.23.2: Cycle ai_mode on click: auto → mention_only → off → auto +async function cycleChannelAiMode(channelId) { + const ch = (App.channels || []).find(c => c.id === channelId); + if (!ch) return; + const order = ['auto', 'mention_only', 'off']; + const idx = order.indexOf(ch.aiMode || 'auto'); + const next = order[(idx + 1) % order.length]; + try { + await API.updateChannel(channelId, { ai_mode: next }); + ch.aiMode = next; + if (typeof UI !== 'undefined') UI.updateContextBanner(); + UI.toast(`AI mode: ${next === 'mention_only' ? '@mention only' : next}`, 'success'); + } catch (e) { + UI.toast('Failed to change AI mode: ' + e.message, 'error'); + } +} + +// ── Folder Menu + CRUD ───────────────────────── + function showFolderMenu(e, folderId) { e.preventDefault(); e.stopPropagation(); @@ -1238,13 +1700,69 @@ async function renameFolder(folderId) { async function deleteFolder(folderId) { const folder = (App.folders || []).find(f => f.id === folderId); if (!folder) return; - // Move any chats in this folder back to unfiled before deleting - (App.chats || []).forEach(c => { if (c.folderId === folderId) c.folderId = null; }); + + const chatsInFolder = (App.chats || []).filter(c => c.folderId === folderId); + + // If folder has chats, ask whether to preserve or delete them + let preserveChats = true; + if (chatsInFolder.length > 0) { + const choice = await new Promise(resolve => { + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay active'; + overlay.innerHTML = ` + `; + overlay.addEventListener('click', e => { if (e.target === overlay) { overlay.remove(); resolve(null); } }); + overlay.querySelector('#_dfCancel').onclick = () => { overlay.remove(); resolve(null); }; + overlay.querySelector('#_dfDeleteAll').onclick = () => { overlay.remove(); resolve('delete'); }; + overlay.querySelector('#_dfPreserve').onclick = () => { overlay.remove(); resolve('preserve'); }; + document.body.appendChild(overlay); + }); + + if (!choice) return; // cancelled + preserveChats = choice === 'preserve'; + } else { + // Empty folder — just confirm + if (!await showConfirm(`Delete folder "${folder.name}"?`)) return; + } + try { + if (preserveChats) { + // Unfile chats before deleting the folder + for (const c of chatsInFolder) { + c.folderId = null; + } + } else { + // Delete the chats in the folder + for (const c of chatsInFolder) { + try { + await API.deleteChannel(c.id); + // Clean up active state if deleting the active chat + if (App.activeId === c.id) { + App.setActive(null); + } + } catch (_) { /* best effort */ } + } + App.chats = App.chats.filter(c => c.folderId !== folderId); + } + await API.deleteFolder(folderId); App.folders = App.folders.filter(f => f.id !== folderId); - if (typeof UI !== 'undefined') UI.renderChatList(); - UI.toast('Folder deleted', 'success'); + if (typeof UI !== 'undefined') { + UI.renderChatList(); + if (!preserveChats && !App.activeId) UI.showEmptyState(); + } + UI.toast(preserveChats ? 'Folder deleted, chats preserved' : 'Folder and chats deleted', 'success'); } catch (e) { UI.toast('Failed to delete folder', 'error'); console.error('[deleteFolder]', e); @@ -1255,11 +1773,172 @@ async function deleteFolder(folderId) { (function startPresenceHeartbeat() { const INTERVAL = 30000; // 30s + let timer = null; async function beat() { if (!API.isAuthed) return; try { await API.presenceHeartbeat(); } catch (_) { /* non-critical */ } } - setInterval(beat, INTERVAL); + function start() { + if (timer) return; + beat(); // immediate on resume + timer = setInterval(beat, INTERVAL); + } + function stop() { + if (timer) { clearInterval(timer); timer = null; } + } + start(); + document.addEventListener('visibilitychange', () => { + if (document.hidden) stop(); else start(); + }); })(); + +// ── Participants Panel (v0.23.2) ────────────────────────────────────────── + +async function openParticipantsPanel(channelId) { + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay active'; + overlay.id = '_participantsOverlay'; + overlay.innerHTML = ` + `; + overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); }); + overlay.querySelector('#_closeParticipants').onclick = () => overlay.remove(); + overlay.querySelector('#_addUserBtn').onclick = () => _showAddParticipantSearch(channelId, 'user'); + overlay.querySelector('#_addPersonaBtn').onclick = () => _showAddParticipantSearch(channelId, 'persona'); + document.body.appendChild(overlay); + + await _refreshParticipantsList(channelId); +} + +async function _refreshParticipantsList(channelId) { + const el = document.getElementById('_participantsList'); + if (!el) return; + try { + const resp = await API._get(`/api/v1/channels/${channelId}/participants`); + const parts = resp.participants || []; + if (parts.length === 0) { + el.innerHTML = '
No participants yet
'; + return; + } + el.innerHTML = parts.map(p => { + const icon = p.participant_type === 'persona' ? '⚡' : '👤'; + // Resolve name: display_name from record, or look up in App state + let name = p.display_name; + if (!name && p.participant_type === 'user') { + const u = (App.users || []).find(u => u.id === p.participant_id); + name = u?.displayName || u?.username || null; + // Also check current user + if (!name && API.user && API.user.id === p.participant_id) { + name = API.user.display_name || API.user.username; + } + } + if (!name && p.participant_type === 'persona') { + const m = (App.models || []).find(m => m.personaId === p.participant_id); + name = m?.name || null; + } + if (!name) name = p.participant_id.slice(0, 8) + '…'; + const role = p.role || 'member'; + return `
+ ${icon} + ${esc(name)} + ${esc(p.participant_type)} · ${esc(role)} + ${role !== 'owner' ? `` : ''} +
`; + }).join(''); + } catch (e) { + el.innerHTML = `
Failed to load: ${esc(e.message)}
`; + } +} + +async function _showAddParticipantSearch(channelId, pType) { + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay active'; + overlay.style.zIndex = '10001'; + const label = pType === 'persona' ? 'Persona' : 'User'; + overlay.innerHTML = ` + `; + overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); }); + document.body.appendChild(overlay); + + const input = overlay.querySelector('#_addPartSearch'); + const results = overlay.querySelector('#_addPartResults'); + input.focus(); + + let debounce = null; + input.addEventListener('input', () => { + clearTimeout(debounce); + debounce = setTimeout(async () => { + const q = input.value.trim(); + if (q.length < 1) { results.innerHTML = ''; return; } + try { + let items = []; + if (pType === 'user') { + const resp = await API._get(`/api/v1/users/search?q=${encodeURIComponent(q)}`); + items = (resp.users || []).map(u => ({ id: u.id, name: u.display_name || u.username })); + } else { + // Search personas from App.models + items = (App.models || []) + .filter(m => m.isPersona && (m.name || '').toLowerCase().includes(q.toLowerCase())) + .map(m => ({ id: m.personaId, name: m.name })); + } + if (items.length === 0) { + results.innerHTML = '
No matches
'; + return; + } + results.innerHTML = items.slice(0, 10).map(item => + `
+ ${pType === 'persona' ? '⚡' : '👤'} ${esc(item.name)} +
` + ).join(''); + } catch (_) { + results.innerHTML = '
Search failed
'; + } + }, 250); + }); +} + +async function _addParticipant(channelId, pType, participantId) { + try { + await API._post(`/api/v1/channels/${channelId}/participants`, { + participant_type: pType, + participant_id: participantId, + role: 'member', + }); + UI.toast(`${pType === 'persona' ? 'Persona' : 'User'} added`, 'success'); + await _refreshParticipantsList(channelId); + } catch (e) { + UI.toast('Failed to add: ' + e.message, 'error'); + } +} + +async function _removeParticipant(channelId, recordId) { + try { + await API._del(`/api/v1/channels/${channelId}/participants/${recordId}`); + UI.toast('Participant removed', 'success'); + await _refreshParticipantsList(channelId); + } catch (e) { + UI.toast('Failed to remove: ' + e.message, 'error'); + } +} diff --git a/src/js/tokens.js b/src/js/tokens.js index 86f3886..aaa3fcb 100644 --- a/src/js/tokens.js +++ b/src/js/tokens.js @@ -81,7 +81,7 @@ function updateInputTokens() { const budget = Tokens.getContextBudget(); if (budget.maxContext > 0) { // Show relative to available context - const chat = App.chats.find(c => c.id === App.currentChatId); + const chat = App.chats.find(c => c.id === App.activeId); const convTokens = Tokens.estimateConversation(chat?.messages || [], App.settings.systemPrompt); // Include staged file estimates const fileTokens = Tokens.estimateFiles( @@ -114,7 +114,7 @@ function updateContextWarning() { return; } - const chat = App.chats.find(c => c.id === App.currentChatId); + const chat = App.chats.find(c => c.id === App.activeId); if (!chat || !chat.messages?.length) { warning.style.display = 'none'; return; diff --git a/src/js/ui-admin.js b/src/js/ui-admin.js index 649414b..35bf776 100644 --- a/src/js/ui-admin.js +++ b/src/js/ui-admin.js @@ -47,6 +47,7 @@ const ADMIN_LOADERS = { usage: () => UI.loadAdminUsage(), audit: () => UI.loadAuditLog(), stats: () => UI.loadAdminStats(), + channels: () => UI.loadAdminChannels(), }; // Find which category a section belongs to @@ -1447,4 +1448,55 @@ Object.assign(UI, { prev.style.color = fg; }, + // ── Admin Channels (v0.23.2) ────────────── + + async loadAdminChannels() { + const list = document.getElementById('adminArchivedList'); + const countEl = document.getElementById('adminArchivedCount'); + const modeEl = document.getElementById('adminRetentionMode'); + if (!list) return; + + try { + const resp = await API._get('/api/v1/admin/channels/archived'); + const channels = resp.channels || []; + if (countEl) countEl.textContent = channels.length; + if (modeEl) modeEl.textContent = resp.retention_mode || 'flexible'; + + if (channels.length === 0) { + list.innerHTML = '
No archived channels
'; + return; + } + + let html = '' + + '' + + ''; + for (const ch of channels) { + const age = _relativeTime(ch.updated_at); + html += ` + + + + + + + `; + } + html += '
TitleTypeOwnerMessagesArchived
${esc(ch.title || 'Untitled')}${esc(ch.type)}${esc(ch.owner_name)}${ch.message_count}${age}
'; + list.innerHTML = html; + } catch (e) { + list.innerHTML = `
Failed to load: ${esc(e.message)}
`; + } + }, + + async _purgeChannel(channelId) { + if (!await showConfirm('Permanently delete this archived channel and all its messages? This cannot be undone.')) return; + try { + await API._del(`/api/v1/admin/channels/${channelId}/purge`); + UI.toast('Channel purged', 'success'); + UI.loadAdminChannels(); // refresh list + } catch (e) { + UI.toast('Purge failed: ' + e.message, 'error'); + } + }, + }); diff --git a/src/js/ui-core.js b/src/js/ui-core.js index 5412ad6..7cf97db 100644 --- a/src/js/ui-core.js +++ b/src/js/ui-core.js @@ -12,7 +12,7 @@ function avatarHTML(role, avatarDataURI) { if (avatarDataURI) { return ``; } - return role === 'user' ? '👤' : '🤖'; + return role === 'user' ? '👤' : role === 'other-user' ? '👤' : '🤖'; } // Look up the current model/persona avatar for assistant messages. @@ -394,7 +394,7 @@ const UI = { let html = ''; channels.forEach(ch => { - const isActive = App.currentChannelId === ch.id; + const isActive = App.activeId === ch.id; const isDM = ch.type === 'dm'; const isChannel = ch.type === 'channel'; const online = App.presence?.[ch.dmPartnerId] === 'online'; @@ -413,9 +413,11 @@ const UI = { html += `
${icon} - ${!collapsed ? `${esc(ch.name)}${onlineDot}${unreadBadge}` : unreadBadge} + ${!collapsed ? `${esc(ch.name)}${onlineDot}${unreadBadge} + ` : unreadBadge}
`; }); @@ -470,18 +472,21 @@ const UI = { let html = ''; - // Folder groups — always render, even when empty + // Folder groups — always render, even when empty (with drag handlers) folders.forEach(f => { const items = folderMap[f.id] || []; const isOpen = !App.collapsedFolders?.[f.id]; - html += `
+ html += `
${esc(f.name)} - ${isOpen ? '▾' : '▸'} + ${isOpen ? '▾' : '▸'}
${isOpen ? (items.length > 0 @@ -503,6 +508,17 @@ const UI = { else groups.older.push(c); }); + // When folders exist, wrap unfiled chats in a drop zone + if (folders.length > 0) { + html += `
`; + if (unfiled.length === 0) { + html += '
Drop here to unfile
'; + } + } + const renderTimeGroup = (label, items) => { if (!items.length) return; html += `
${label}
`; @@ -514,6 +530,10 @@ const UI = { renderTimeGroup('Previous 7 days', groups.week); renderTimeGroup('Older', groups.older); + if (folders.length > 0) { + html += '
'; // close .sb-unfiled-zone + } + el.innerHTML = html; // Always keep projects section in sync @@ -528,7 +548,7 @@ const UI = { _chatItemHTML(c, indented = false) { const time = _relativeTime(c.updatedAt); - const active = c.id === App.currentChatId ? ' active' : ''; + const active = c.id === App.activeId ? ' active' : ''; const indent = indented ? ' indented' : ''; const typeIcon = c.type === 'group' ? '👥 ' @@ -617,19 +637,40 @@ const UI = { // Skip summary messages in normal rendering — they get _summaryHTML if (_isSummaryMessage(msg)) return ''; const time = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''; - let assistantLabel = 'Assistant'; - if (!isUser) { - // Use stored modelName, or resolve from channel roster, or model list, or fall back - if (msg.modelName) { - assistantLabel = msg.modelName; + + // v0.23.2: Sender attribution — distinguish self from other humans + const isSelf = !isUser ? false + : (!msg.participant_id || msg.participant_id === API.user?.id); + const isOtherUser = isUser && !isSelf; + + let senderLabel = 'You'; + let senderAvatarURI = API.user?.avatar || null; + + if (isOtherUser) { + senderLabel = msg.sender_name || 'User'; + senderAvatarURI = msg.sender_avatar || null; + } else if (!isUser) { + // Assistant message — use model/persona label + senderLabel = 'Assistant'; + senderAvatarURI = assistantAvatarURI(msg); + if (msg.sender_name) { + senderLabel = msg.sender_name; + } else if (msg.modelName) { + senderLabel = msg.modelName; } else if (typeof ChannelModels !== 'undefined' && msg.model) { - assistantLabel = ChannelModels.resolveDisplayName(msg.model); + senderLabel = ChannelModels.resolveDisplayName(msg.model); } else if (msg.model) { const m = App.models.find(x => x.id === msg.model || x.baseModelId === msg.model); - assistantLabel = m?.name || msg.model; + senderLabel = m?.name || msg.model; + } + if (msg.sender_avatar) { + senderAvatarURI = msg.sender_avatar; } } + // CSS class: self = right-aligned "user", other humans = left-aligned "other-user", AI = "assistant" + const roleClass = isSelf ? 'user' : isOtherUser ? 'other-user' : msg.role; + // Branch indicator: show ‹ 2/3 › when siblings exist const hasSiblings = (msg.siblingCount || 1) > 1; const sibPos = (msg.siblingIndex || 0) + 1; // display as 1-indexed @@ -645,16 +686,16 @@ const UI = { // Action buttons const msgId = msg.id ? esc(msg.id) : ''; - const editBtn = isUser && msgId ? `` : ''; + const editBtn = isSelf && msgId ? `` : ''; const regenBtn = !isUser && msgId ? `` : ''; return ` -
+
-
${avatarHTML(msg.role, isUser ? API.user?.avatar : assistantAvatarURI(msg))}
+
${avatarHTML(isOtherUser ? 'other-user' : msg.role, senderAvatarURI)}
- ${isUser ? 'You' : esc(assistantLabel)} + ${esc(senderLabel)} ${branchNav} ${time ? `${time}` : ''}
@@ -680,6 +721,45 @@ const UI = {

Chat Switchboard

Select a model and start chatting

`; + this.updateContextBanner(); + }, + + // v0.23.2: Show/hide context banner based on active conversation type + updateContextBanner() { + const el = document.getElementById('channelContextBanner'); + if (!el) return; + const ac = App.activeConversation; + if (!ac || ac.type === 'direct') { + el.style.display = 'none'; + return; + } + + let html = ''; + if (ac.type === 'dm') { + const ch = (App.channels || []).find(c => c.id === ac.id); + const name = ch?.name || 'someone'; + html = `DM` + + `with ${esc(name)}` + + `@mention a persona to bring in AI` + + `` + + ``; + } else if (ac.type === 'group') { + html = `Group` + + `No @mention = leader responds · @mention to route · @all for everyone` + + `` + + ``; + } else if (ac.type === 'channel') { + const ch = (App.channels || []).find(c => c.id === ac.id); + const mode = ch?.aiMode || 'auto'; + const topic = ch?.topic || ''; + const modeLabel = mode === 'auto' ? 'AI: auto' : mode === 'mention_only' ? 'AI: @mention only' : 'AI: off'; + html = ``; + if (topic) html += `${esc(topic)}`; + html += ``; + html += ``; + } + el.innerHTML = html; + el.style.display = ''; }, showEditInline(messageId, currentContent) { @@ -1278,7 +1358,7 @@ const UI = { // ── Export ──────────────────────────────── exportChat(format) { - const chat = App.chats.find(c => c.id === App.currentChatId); + const chat = App.getActiveChat(); if (!chat) return UI.toast('No chat to export', 'warning'); let content, ext, mime; @@ -1302,7 +1382,7 @@ const UI = { // ── Message Actions ────────────────────── copyMessage(index) { - const chat = App.chats.find(c => c.id === App.currentChatId); + const chat = App.getActiveChat(); if (chat?.messages[index]) { navigator.clipboard.writeText(chat.messages[index].content) .then(() => UI.toast('Copied', 'success')) diff --git a/src/js/ui-format.js b/src/js/ui-format.js index 517b3d0..89787f0 100644 --- a/src/js/ui-format.js +++ b/src/js/ui-format.js @@ -58,35 +58,44 @@ function formatMessage(content) { function _highlightMentions(html) { if (typeof ChannelModels === 'undefined') return html; const roster = ChannelModels.getRoster(); - if (!roster || roster.length < 2) return html; - // Build patterns from handles (primary) and display names (fallback) - const patterns = []; + // Build patterns from roster handles and display names (AI mentions) + const aiPatterns = []; const seen = new Set(); - for (const m of roster) { - // Handle pattern (preferred) - if (m.handle) { - const escaped = m.handle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - if (!seen.has(escaped)) { - patterns.push(new RegExp('(@' + escaped + ')(?=[\\s.,!?;:)\\]}<]|$)', 'gi')); - seen.add(escaped); + if (roster && roster.length >= 2) { + for (const m of roster) { + if (m.handle) { + const escaped = m.handle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + if (!seen.has(escaped)) { + aiPatterns.push(new RegExp('(@' + escaped + ')(?=[\\s.,!?;:)\\]}<]|$)', 'gi')); + seen.add(escaped); + } } - } - // Display name pattern (hyphenated form) - if (m.display_name) { - const hyphenated = m.display_name.replace(/\s+/g, '-'); - const escaped = hyphenated.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - if (!seen.has(escaped.toLowerCase())) { - const flexible = escaped.replace(/[-]+/g, '[\\s-]+'); - patterns.push(new RegExp('(@' + flexible + ')(?=[\\s.,!?;:)\\]}<]|$)', 'gi')); - seen.add(escaped.toLowerCase()); + if (m.display_name) { + const hyphenated = m.display_name.replace(/\s+/g, '-'); + const escaped = hyphenated.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + if (!seen.has(escaped.toLowerCase())) { + const flexible = escaped.replace(/[-]+/g, '[\\s-]+'); + aiPatterns.push(new RegExp('(@' + flexible + ')(?=[\\s.,!?;:)\\]}<]|$)', 'gi')); + seen.add(escaped.toLowerCase()); + } } } } - // @all - patterns.push(new RegExp('(@all)(?=[\\s.,!?;:)\\]}<]|$)', 'gi')); + // @all is special + aiPatterns.push(new RegExp('(@all)(?=[\\s.,!?;:)\\]}<]|$)', 'gi')); + + // v0.23.2: Build user mention patterns + const userPatterns = []; + for (const u of (App.users || [])) { + const escaped = u.username.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + if (!seen.has(escaped.toLowerCase())) { + userPatterns.push(new RegExp('(@' + escaped + ')(?=[\\s.,!?;:)\\]}<]|$)', 'gi')); + seen.add(escaped.toLowerCase()); + } + } // Don't highlight inside ,
,  tags
     const parts = html.split(/(<[^>]+>)/);
@@ -101,9 +110,14 @@ function _highlightMentions(html) {
         }
         if (inCode) continue;
         let text = part;
-        for (const re of patterns) {
+        // AI mentions first (persona/model)
+        for (const re of aiPatterns) {
             text = text.replace(re, '$1');
         }
+        // User mentions (different color)
+        for (const re of userPatterns) {
+            text = text.replace(re, '$1');
+        }
         parts[i] = text;
     }
     return parts.join('');
diff --git a/src/js/ui-settings.js b/src/js/ui-settings.js
index 933f47e..76053e4 100644
--- a/src/js/ui-settings.js
+++ b/src/js/ui-settings.js
@@ -106,6 +106,11 @@ Object.assign(UI, {
         const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
         if (scaleEl) prefs.scale = parseInt(scaleEl.value);
         if (msgFontEl) prefs.msgFont = parseInt(msgFontEl.value);
+        // v0.23.2: Theme saved via Theme API (localStorage key: switchboard_theme)
+        const activeThemeBtn = document.querySelector('#themeToggle .theme-btn.active');
+        if (activeThemeBtn && typeof Theme !== 'undefined') {
+            Theme.set(activeThemeBtn.dataset.theme);
+        }
         localStorage.setItem('cs-appearance', JSON.stringify(prefs));
         UI.toast('Appearance saved', 'success');
     },
@@ -141,7 +146,8 @@ Object.assign(UI, {
         const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
         const scale = prefs.scale || 100;
         const msgFont = prefs.msgFont || 14;
-        const theme = prefs.theme || 'system';
+        // v0.23.2: Read theme from Theme API
+        const theme = typeof Theme !== 'undefined' ? Theme.get() : 'system';
         const keymap = prefs.editorKeymap || 'standard';
 
         const scaleEl = document.getElementById('settingsScale');
@@ -149,9 +155,14 @@ Object.assign(UI, {
         if (scaleEl) { scaleEl.value = scale; document.getElementById('scaleValue').textContent = scale + '%'; }
         if (msgFontEl) { msgFontEl.value = msgFont; document.getElementById('msgFontValue').textContent = msgFont + 'px'; }
 
-        // Highlight active theme button
+        // Highlight active theme button and wire click
         document.querySelectorAll('#themeToggle .theme-btn').forEach(btn => {
             btn.classList.toggle('active', btn.dataset.theme === theme);
+            btn.onclick = () => {
+                document.querySelectorAll('#themeToggle .theme-btn').forEach(b => b.classList.remove('active'));
+                btn.classList.add('active');
+                if (typeof Theme !== 'undefined') Theme.set(btn.dataset.theme);
+            };
         });
 
         // Highlight active keymap button