From eb741806119be94fa9b5f1fe90d037abc95e5aa4 Mon Sep 17 00:00:00 2001 From: xcaliber Date: Sun, 1 Mar 2026 01:05:54 +0000 Subject: [PATCH] Changeset 0.19.2 (#84) --- CHANGELOG.md | 7 ++ VERSION | 2 +- docs/DESIGN-0.19.2.md | 116 ++++++++++++++++++++ docs/ROADMAP.md | 76 ++++++++++++- server/handlers/completion.go | 29 +++++ src/css/styles.css | 21 ++++ src/js/app.js | 2 + src/js/projects-ui.js | 196 +++++++++++++++++++++++++++++++++- src/js/ui-core.js | 42 +++++--- 9 files changed, 473 insertions(+), 18 deletions(-) create mode 100644 docs/DESIGN-0.19.2.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 46683e7..dd71cad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to Chat Switchboard. +## [0.19.2] — 2026-02-28 + +### Added +- **Project Persona Default**: Bind a persona to a project via the detail panel. All chats in the project inherit the persona's model, parameters, and system prompt as a fallback when no explicit preset is selected. Resolution chain: explicit request → project persona → none. +- **Project Archive Toggle**: Archive/unarchive projects from the detail panel. Archived projects hidden from sidebar by default with "Show archived (N)" toggle. Dimmed visual treatment when shown. +- **Channel Reorder**: Right-click a chat within a project for "Move up" / "Move down" options. Server-persisted positions via `project_channels.position`, loaded on startup, maintained across moves. + ## [0.19.1] — 2026-02-28 ### Added diff --git a/VERSION b/VERSION index 4559101..7f78682 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.19.1 \ No newline at end of file +0.19.2 \ No newline at end of file diff --git a/docs/DESIGN-0.19.2.md b/docs/DESIGN-0.19.2.md new file mode 100644 index 0000000..3c09073 --- /dev/null +++ b/docs/DESIGN-0.19.2.md @@ -0,0 +1,116 @@ +# v0.19.2 — Project Persona Default, Archive Toggle, Channel Reorder + +**Status:** Implementation +**Depends on:** v0.19.1 + +--- + +## 1. Project-level Persona Default + +### Concept + +Bind a persona to a project so all chats inherit model, system prompt, +and parameters without selecting it each time. The persona acts as a +fallback when no explicit preset is chosen per-message. + +### Resolution Chain (completion.go) + +``` +1. Explicit req.PresetID → highest priority +2. Project persona default → fallback when no explicit preset ← NEW +3. No persona → bare model, no persona context +``` + +### Storage + +`projects.settings` JSONB: +```json +{ "persona_id": "uuid-here", "system_prompt": "..." } +``` + +### Backend Changes + +In `completion.go`, after the explicit preset block, before +`resolveConfig`: + +```go +// ── Project persona fallback (v0.19.2) ── +if req.PresetID == "" && h.stores.Projects != nil { + projID, _ := h.stores.Projects.GetProjectIDForChannel(ctx, channelID) + if projID != "" { + proj, _ := h.stores.Projects.GetByID(ctx, projID) + if proj != nil { + if pid, ok := proj.Settings["persona_id"].(string); ok && pid != "" { + preset := ResolvePreset(h.stores, pid, userID) + if preset != nil { + personaID = preset.ID + if req.Model == "" { req.Model = preset.BaseModelID } + // ... same defaults as explicit preset + } + } + } + } +} +``` + +### Frontend Changes + +Project detail panel: persona picker dropdown below system prompt. +Lists available personas, saves to `settings.persona_id`. + +--- + +## 2. Project Archive Toggle + +### Behavior + +- Toggle in project detail panel (checkbox) +- Archived projects hidden from sidebar by default +- "Show archived" toggle at bottom of sidebar (or in panel) +- Uses existing `is_archived` column — no migration needed + +### Files Changed + +- `src/js/projects-ui.js` — archive toggle in panel, sidebar filter +- `src/js/ui-core.js` — filter archived in renderChatList +- `src/css/styles.css` — dimmed style for archived + +--- + +## 3. Channel Reorder Within Project + +### Behavior + +- Dragging a chat within the same project group reorders it +- Drop between chats inserts at that position +- Calls `PUT /projects/:id/channels/reorder` with new order +- Position persisted server-side, respected on next load + +### Implementation + +- `onProjectDrop` detects same-project reorder vs cross-project move +- Compute new channel order from DOM after drop +- Channels within project sorted by position from + `project_channels.position` (loaded alongside projects) + +### Files Changed + +- `src/js/projects-ui.js` — reorder logic +- `src/js/ui-core.js` — sort projChats by position +- `src/js/app.js` — store project channel positions + +--- + +## Files Summary + +| File | Change | +|------|--------| +| `server/handlers/completion.go` | Project persona fallback | +| `src/js/projects-ui.js` | Persona picker, archive toggle, reorder | +| `src/js/ui-core.js` | Archive filter, position sort | +| `src/js/app.js` | Channel positions state | +| `src/css/styles.css` | Archive styles | +| `VERSION` | 0.19.1 → 0.19.2 | +| `CHANGELOG.md` | New entry | + +No migrations. No new files. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 84e08e9..7f0a06f 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -72,6 +72,10 @@ v0.18.1 Side Panel Architecture ✅ (single-slot, ctx.ui primitives, mermaid ref │ v0.19.0 Projects / Workspaces ✅ (project groups, KB resolution, drag-drop sidebar) │ +v0.19.1 Active Project + System Prompt + Detail Panel ✅ + │ +v0.19.2 Project Persona Default + Archive + Reorder ✅ + │ v0.20.0 Notifications + @mention Routing + Multi-model │ v0.21.0 Extension Surfaces + Modes (depends on CM6 from v0.17.2) @@ -527,15 +531,76 @@ Depends on: user groups (v0.16.0), knowledge bases (v0.14.0). **Deferred to later versions:** - Project creation dialog (name, description, default Persona, default KBs) — using prompt() for now -- Project detail panel (KB + note management within a project) -- Project-level Persona default +- ~~Project detail panel (KB + note management within a project)~~ → v0.19.1 +- ~~Project-level Persona default~~ → v0.19.2 - Folders within projects - Project templates - Admin-level team/global project management +- Project-specific files (full project-level upload — deferred for proper architecture) - Notifications (moved to v0.20.0) --- +## ✅ v0.19.1 — Active Project + Project System Prompt + Detail Panel + +Quality-of-life improvements making projects usable for daily workflow. +No migrations. + +Depends on: v0.19.0 Projects/Workspaces. + +**Active Project** +- [x] Pin a project as active — new chats auto-assign via `addChannelToProject` after channel creation +- [x] `App.activeProjectId` persisted in `localStorage('cs-active-project')` +- [x] Visual indicator: `.project-group.active` accent border + 📌 icon +- [x] Toggle from project ⋯ menu ("Pin as active" / "Unpin project") +- [x] Cleared on project delete + +**Project System Prompt** +- [x] Per-project instructions in `projects.settings` JSONB (`system_prompt` key) +- [x] Injected in `loadConversation` between persona/channel prompt and KB hint +- [x] Merge semantics on update: reads existing JSONB, overlays patch keys, writes back +- [x] Both Postgres and SQLite stores updated + +**Project Detail Panel** +- [x] Registered with `PanelRegistry` (same pattern as Notes, Preview) +- [x] System prompt textarea with Save button +- [x] KB management: list with names (LEFT JOIN), [+ Add] picker dropdown, [✕] remove +- [x] Notes list with titles (LEFT JOIN), [✕] unbind +- [x] Accessible from project ⋯ menu → "Project settings" + +**Bug Fixes** +- [x] `renderChatList` early return prevented project groups from rendering when zero chats +- [x] Recent section drop target: "Drop chats here to unassign" hint with `min-height: 40px` + +--- + +## ✅ v0.19.2 — Project Persona Default + Archive + Channel Reorder + +Completes the project management feature set. No migrations. + +Depends on: v0.19.1. + +**Project Persona Default** +- [x] Bind a persona to a project via detail panel dropdown +- [x] Stored in `projects.settings.persona_id` +- [x] Resolution chain: explicit `req.PresetID` → project persona → none +- [x] Inherits model, provider config, temperature, max tokens, system prompt as defaults + +**Project Archive Toggle** +- [x] Checkbox in project detail panel +- [x] Archived projects hidden from sidebar by default +- [x] "▸ Show archived (N)" toggle button below project list +- [x] Chats in hidden-archived projects spill into Recent section (never vanish) +- [x] Dimmed + italic visual treatment for archived groups + +**Channel Reorder** +- [x] `loadProjectChannelPositions()` fetches server-persisted positions on startup +- [x] `_getReorderedProjectChats()` sorts project chats by stored position +- [x] Right-click context menu: ↑ Move up / ↓ Move down (optimistic swap + API call) +- [x] Order maintained across add/remove/move operations + +--- + ## v0.20.0 — Notifications + @mention Routing + Multi-model Notification infrastructure that makes collaboration real-time, plus @@ -778,6 +843,13 @@ based on need. - "Group" scope badge on model selector / KB list: show access-source annotation so users see _why_ they have access (global, team, group grant). Requires backend to include `access_source` in list responses. - Per-provider model preferences: `user_model_settings` unique key is `(user_id, model_id)` — same model from different providers shares one visibility toggle. Needs `provider_config_id` dimension in DB constraint, store, API, and frontend `hiddenModels` keying. Frontend composite key (`configId:modelId`) already exists in `App.models[].id`. +**Projects — Future** +- Project-specific files: full project-level upload (own endpoint, storage path, UI surface — not just channel attachment re-linking) +- Project templates: create new projects from predefined configurations (persona, KBs, system prompt) +- Project creation dialog: replace `prompt()` with proper modal (name, description, persona, KB picker) +- Admin-level project management: cross-instance visibility, reassign ownership, enforce team policies (scope: enterprise only, BYOK personal projects stay private) +- Sub-projects / nested hierarchy: child inherits parent KBs, system prompt, persona (deferred until usage patterns clarify need vs tags/labels) + **Knowledge Bases — Future** - KB auto-injection: top-K chunk prepend to system prompt, context budget aware, per-channel toggle (latency budgeting required) - Hybrid search: combine vector similarity with full-text `tsvector`, re-rank diff --git a/server/handlers/completion.go b/server/handlers/completion.go index 3925c42..80c67fd 100644 --- a/server/handlers/completion.go +++ b/server/handlers/completion.go @@ -122,6 +122,35 @@ func (h *CompletionHandler) Complete(c *gin.Context) { } } + // ── Project persona fallback (v0.19.2): if no explicit preset, check project ── + if req.PresetID == "" && h.stores.Projects != nil { + projID, _ := h.stores.Projects.GetProjectIDForChannel(context.Background(), channelID) + if projID != "" { + if proj, err := h.stores.Projects.GetByID(context.Background(), projID); err == nil { + if pid, ok := proj.Settings["persona_id"].(string); ok && pid != "" { + if preset := ResolvePreset(h.stores, pid, userID); preset != nil { + personaID = preset.ID + if req.Model == "" { + req.Model = preset.BaseModelID + } + if req.APIConfigID == "" && preset.ProviderConfigID != nil { + req.APIConfigID = *preset.ProviderConfigID + } + if req.Temperature == nil && preset.Temperature != nil { + req.Temperature = preset.Temperature + } + if req.MaxTokens == 0 && preset.MaxTokens != nil { + req.MaxTokens = *preset.MaxTokens + } + if preset.SystemPrompt != "" { + presetSystemPrompt = preset.SystemPrompt + } + } + } + } + } + } + // Resolve provider config providerCfg, providerID, model, configID, providerScope, err := h.resolveConfig(userID, channelID, req) if err != nil { diff --git a/src/css/styles.css b/src/css/styles.css index 15e0f30..cbe7d3e 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -2490,3 +2490,24 @@ select option { background: var(--bg-surface); color: var(--text); } } .project-panel-item-remove:hover { color: var(--danger, #ef4444); background: var(--danger-bg, #fef2f2); } .project-panel-empty { font-size: 12px; color: var(--text-3); font-style: italic; padding: 4px 0; } +.project-panel-select { + width: 100%; padding: 6px 8px; font-size: 13px; + background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); + color: var(--text); +} +.project-panel-select:focus { outline: none; border-color: var(--accent); } +.project-panel-hint { font-size: 11px; color: var(--text-3); margin-top: 2px; } +.project-panel-checkbox { + display: flex; align-items: center; gap: 8px; font-size: 13px; + cursor: pointer; color: var(--text-2); +} +.project-panel-checkbox input { cursor: pointer; } +.project-panel-footer { padding-top: 8px; border-top: 1px solid var(--border); } +.project-show-archived { + background: none; border: none; cursor: pointer; + font-size: 11px; color: var(--text-3); padding: 6px 10px; + width: 100%; text-align: left; +} +.project-show-archived:hover { color: var(--text-2); } +.project-group.archived { opacity: 0.5; } +.project-group.archived .project-header { font-style: italic; } diff --git a/src/js/app.js b/src/js/app.js index 0f20984..dd352c8 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -20,6 +20,7 @@ const App = { projects: [], // v0.19.0: loaded from /api/v1/projects collapsedProjects: {}, // projectId → bool — sidebar collapse state activeProjectId: null, // v0.19.1: new chats auto-assign to this project + showArchivedProjects: false, // v0.19.2: toggle archived projects in sidebar // Find model by composite ID, with fallback to bare model_id match findModel(id) { @@ -183,6 +184,7 @@ async function startApp() { await loadChats(); await loadProjects(); + await loadProjectChannelPositions(); // v0.19.2: load channel order per project await fetchModels(); // Guard: if token was invalidated during startup (401 → refresh fail → clearTokens), diff --git a/src/js/projects-ui.js b/src/js/projects-ui.js index c9a9170..40e2f58 100644 --- a/src/js/projects-ui.js +++ b/src/js/projects-ui.js @@ -157,6 +157,9 @@ async function moveChatToProject(chatId, projectId) { try { await API.removeChannelFromProject(oldProjectId, chatId); chat.projectId = null; + // Remove from order tracking (v0.19.2) + const order = getProjectChannelOrder(oldProjectId); + setProjectChannelOrder(oldProjectId, order.filter(id => id !== chatId)); UI.renderChatList(); } catch (e) { UI.toast('Failed to remove from project', 'error'); @@ -165,6 +168,15 @@ async function moveChatToProject(chatId, projectId) { try { await API.addChannelToProject(projectId, chatId, 0); chat.projectId = projectId; + // Add to order tracking (v0.19.2) + const order = getProjectChannelOrder(projectId); + if (!order.includes(chatId)) order.push(chatId); + setProjectChannelOrder(projectId, order); + // Remove from old project order + if (oldProjectId) { + const oldOrder = getProjectChannelOrder(oldProjectId); + setProjectChannelOrder(oldProjectId, oldOrder.filter(id => id !== chatId)); + } UI.renderChatList(); } catch (e) { UI.toast('Failed to move to project', 'error'); @@ -190,6 +202,21 @@ function showChatContextMenu(e, chatId) { // Move to project submenu let items = ''; if (chat.projectId) { + // Reorder within project (v0.19.2) + const order = getProjectChannelOrder(chat.projectId); + const idx = order.indexOf(chatId); + if (idx > 0) { + items += ``; + } + if (idx >= 0 && idx < order.length - 1) { + items += ``; + } + if (idx >= 0) items += '
'; + items += ``; @@ -347,6 +374,13 @@ function _registerProjectPanel() { +
+ + +
New chats in this project inherit the persona's model, parameters, and system prompt.
+
@@ -358,6 +392,12 @@ function _registerProjectPanel() {
+
`; body.appendChild(el); @@ -367,6 +407,12 @@ function _registerProjectPanel() { // Wire KB add button el.querySelector('#projectAddKB').addEventListener('click', _showKBPicker); + // Wire persona select (v0.19.2) + el.querySelector('#projectPersonaSelect').addEventListener('change', _saveProjectPersona); + + // Wire archive toggle (v0.19.2) + el.querySelector('#projectArchiveToggle').addEventListener('change', _toggleProjectArchive); + PanelRegistry.register('project', { element: el, label: 'Project', @@ -391,15 +437,21 @@ async function _loadProjectPanel(projectId) { if (!proj) return; // Load full project data from server (includes settings) + let fullSettings = {}; try { const full = await API.getProject(projectId); - const sp = (full.settings && full.settings.system_prompt) || ''; - document.getElementById('projectSystemPrompt').value = sp; + fullSettings = full.settings || {}; + document.getElementById('projectSystemPrompt').value = fullSettings.system_prompt || ''; document.getElementById('projectPromptStatus').textContent = ''; + // Archive toggle + document.getElementById('projectArchiveToggle').checked = !!full.is_archived; } catch (_) { document.getElementById('projectSystemPrompt').value = ''; } + // Populate persona picker (v0.19.2) + await _renderPersonaPicker(fullSettings.persona_id || ''); + // Load KBs await _renderProjectKBs(projectId); @@ -537,3 +589,143 @@ async function _removeProjectNote(projectId, noteId) { UI.toast('Failed to remove note', 'error'); } } + +// ═══════════════════════════════════════════ +// PERSONA PICKER (v0.19.2) +// ═══════════════════════════════════════════ + +async function _renderPersonaPicker(currentPersonaId) { + const select = document.getElementById('projectPersonaSelect'); + if (!select) return; + select.innerHTML = ''; + try { + const resp = await API.listPresets(); + const personas = resp.data || resp || []; + personas.forEach(p => { + const opt = document.createElement('option'); + opt.value = p.id; + opt.textContent = p.name + (p.base_model_id ? ` (${p.base_model_id})` : ''); + if (p.id === currentPersonaId) opt.selected = true; + select.appendChild(opt); + }); + } catch (_) { /* best effort */ } +} + +async function _saveProjectPersona() { + if (!_projectPanelId) return; + const personaId = document.getElementById('projectPersonaSelect').value; + try { + await API.updateProject(_projectPanelId, { + settings: { persona_id: personaId || null } + }); + UI.toast(personaId ? 'Persona set' : 'Persona cleared', 'success'); + } catch (e) { + UI.toast('Failed to update persona', 'error'); + } +} + +// ═══════════════════════════════════════════ +// ARCHIVE TOGGLE (v0.19.2) +// ═══════════════════════════════════════════ + +async function _toggleProjectArchive() { + if (!_projectPanelId) return; + const checked = document.getElementById('projectArchiveToggle').checked; + try { + await API.updateProject(_projectPanelId, { is_archived: checked }); + const proj = App.projects.find(p => p.id === _projectPanelId); + if (proj) proj.isArchived = checked; + UI.renderChatList(); + UI.toast(checked ? 'Project archived' : 'Project unarchived', 'success'); + } catch (e) { + UI.toast('Failed to update archive status', 'error'); + document.getElementById('projectArchiveToggle').checked = !checked; + } +} + +// ═══════════════════════════════════════════ +// CHANNEL REORDER WITHIN PROJECT (v0.19.2) +// ═══════════════════════════════════════════ + +// Stores project channel order: projectId → [channelId, ...] +const _projectChannelOrder = {}; + +function getProjectChannelOrder(projectId) { + return _projectChannelOrder[projectId] || []; +} + +function setProjectChannelOrder(projectId, channelIds) { + _projectChannelOrder[projectId] = channelIds; +} + +// Called after loadProjects + loadChats to initialize order from server positions +async function loadProjectChannelPositions() { + for (const proj of App.projects) { + try { + const resp = await API.listProjectChannels(proj.id); + const channels = resp.data || resp || []; + // Sorted by position from server + setProjectChannelOrder(proj.id, channels.map(c => c.channel_id)); + } catch (_) { /* best effort — fall back to no order */ } + } +} + +function _getReorderedProjectChats(projectId, chats) { + const order = getProjectChannelOrder(projectId); + const projChats = chats.filter(c => c.projectId === projectId); + if (order.length === 0) return projChats; + + // Sort by position order; unordered chats go to end + const posMap = new Map(order.map((id, i) => [id, i])); + return projChats.sort((a, b) => { + const pa = posMap.has(a.id) ? posMap.get(a.id) : 9999; + const pb = posMap.has(b.id) ? posMap.get(b.id) : 9999; + return pa - pb; + }); +} + +async function _reorderOnDrop(e, projectId) { + // Get all chat items in this project group from the DOM + const group = e.currentTarget; + const chatItems = group.querySelectorAll('.chat-item'); + const newOrder = []; + chatItems.forEach(el => { + const onclick = el.getAttribute('onclick') || ''; + const m = onclick.match(/selectChat\('([^']+)'\)/); + if (m) newOrder.push(m[1]); + }); + + if (newOrder.length === 0) return; + + // Optimistic update + setProjectChannelOrder(projectId, newOrder); + + try { + await API.reorderProjectChannels(projectId, newOrder); + } catch (e) { + UI.toast('Failed to save order', 'error'); + } +} + +async function _moveChatInProject(projectId, chatId, direction) { + const order = getProjectChannelOrder(projectId); + const idx = order.indexOf(chatId); + if (idx < 0) return; + const newIdx = idx + direction; + if (newIdx < 0 || newIdx >= order.length) return; + + // Swap + [order[idx], order[newIdx]] = [order[newIdx], order[idx]]; + setProjectChannelOrder(projectId, order); + UI.renderChatList(); + + try { + await API.reorderProjectChannels(projectId, order); + } catch (e) { + // Revert on failure + [order[idx], order[newIdx]] = [order[newIdx], order[idx]]; + setProjectChannelOrder(projectId, order); + UI.renderChatList(); + UI.toast('Failed to reorder', 'error'); + } +} diff --git a/src/js/ui-core.js b/src/js/ui-core.js index f69a2e4..5dd78d6 100644 --- a/src/js/ui-core.js +++ b/src/js/ui-core.js @@ -302,9 +302,15 @@ const UI = { }; // ── Project groups ────────────────────── - if (projects.length > 0) { - projects.forEach(proj => { - const projChats = chats.filter(c => c.projectId === proj.id); + const showArchived = App.showArchivedProjects || false; + const visibleProjects = showArchived ? projects : projects.filter(p => !p.isArchived); + const hasArchived = projects.some(p => p.isArchived); + + if (visibleProjects.length > 0) { + visibleProjects.forEach(proj => { + const projChats = typeof _getReorderedProjectChats === 'function' + ? _getReorderedProjectChats(proj.id, chats) + : chats.filter(c => c.projectId === proj.id); if (projChats.length === 0 && query) return; // hide empty projects during search const isCollapsed = App.collapsedProjects[proj.id]; const colorDot = proj.color @@ -314,7 +320,9 @@ const UI = { const isActive = App.activeProjectId === proj.id; - html += `
@@ -336,14 +344,25 @@ const UI = { } html += '
'; }); + + // "Show archived" toggle + if (hasArchived) { + html += ``; + } } // ── Recent (unassigned) chats ─────────── - const recentChats = chats.filter(c => !c.projectId); + // Include chats from archived-and-hidden projects so they don't vanish + const hiddenProjectIds = new Set( + projects.filter(p => p.isArchived && !showArchived).map(p => p.id) + ); + const recentChats = chats.filter(c => !c.projectId || hiddenProjectIds.has(c.projectId)); - if (recentChats.length > 0 || projects.length > 0) { + if (recentChats.length > 0 || visibleProjects.length > 0) { // Only show "Recent" label if there are also projects - if (projects.length > 0) { + if (visibleProjects.length > 0) { html += `
{ if (items.length === 0) return; - // Only show time labels when there are no projects (preserve original look) - // or when inside the Recent section - if (projects.length > 0) { + if (visibleProjects.length > 0) { html += `
${label}
`; } else { html += `
${label}
`; @@ -388,10 +405,9 @@ const UI = { renderGroup('Previous 7 days', groups.week); renderGroup('Older', groups.older); - if (projects.length > 0 && recentChats.length > 0) { + if (visibleProjects.length > 0 && recentChats.length > 0) { html += '
'; // close recent-section - } else if (projects.length > 0 && recentChats.length === 0) { - // Close the recent-section div we opened + } else if (visibleProjects.length > 0 && recentChats.length === 0) { html += ''; }