Changeset 0.19.2 (#84)
This commit is contained in:
@@ -2,6 +2,13 @@
|
|||||||
|
|
||||||
All notable changes to Chat Switchboard.
|
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
|
## [0.19.1] — 2026-02-28
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
116
docs/DESIGN-0.19.2.md
Normal file
116
docs/DESIGN-0.19.2.md
Normal file
@@ -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.
|
||||||
@@ -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.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.20.0 Notifications + @mention Routing + Multi-model
|
||||||
│
|
│
|
||||||
v0.21.0 Extension Surfaces + Modes (depends on CM6 from v0.17.2)
|
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:**
|
**Deferred to later versions:**
|
||||||
- Project creation dialog (name, description, default Persona, default KBs) — using prompt() for now
|
- Project creation dialog (name, description, default Persona, default KBs) — using prompt() for now
|
||||||
- Project detail panel (KB + note management within a project)
|
- ~~Project detail panel (KB + note management within a project)~~ → v0.19.1
|
||||||
- Project-level Persona default
|
- ~~Project-level Persona default~~ → v0.19.2
|
||||||
- Folders within projects
|
- Folders within projects
|
||||||
- Project templates
|
- Project templates
|
||||||
- Admin-level team/global project management
|
- Admin-level team/global project management
|
||||||
|
- Project-specific files (full project-level upload — deferred for proper architecture)
|
||||||
- Notifications (moved to v0.20.0)
|
- 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
|
## v0.20.0 — Notifications + @mention Routing + Multi-model
|
||||||
|
|
||||||
Notification infrastructure that makes collaboration real-time, plus
|
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.
|
- "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`.
|
- 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**
|
**Knowledge Bases — Future**
|
||||||
- KB auto-injection: top-K chunk prepend to system prompt, context budget aware, per-channel toggle (latency budgeting required)
|
- 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
|
- Hybrid search: combine vector similarity with full-text `tsvector`, re-rank
|
||||||
|
|||||||
@@ -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
|
// Resolve provider config
|
||||||
providerCfg, providerID, model, configID, providerScope, err := h.resolveConfig(userID, channelID, req)
|
providerCfg, providerID, model, configID, providerScope, err := h.resolveConfig(userID, channelID, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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-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-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; }
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const App = {
|
|||||||
projects: [], // v0.19.0: loaded from /api/v1/projects
|
projects: [], // v0.19.0: loaded from /api/v1/projects
|
||||||
collapsedProjects: {}, // projectId → bool — sidebar collapse state
|
collapsedProjects: {}, // projectId → bool — sidebar collapse state
|
||||||
activeProjectId: null, // v0.19.1: new chats auto-assign to this project
|
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
|
// Find model by composite ID, with fallback to bare model_id match
|
||||||
findModel(id) {
|
findModel(id) {
|
||||||
@@ -183,6 +184,7 @@ async function startApp() {
|
|||||||
|
|
||||||
await loadChats();
|
await loadChats();
|
||||||
await loadProjects();
|
await loadProjects();
|
||||||
|
await loadProjectChannelPositions(); // v0.19.2: load channel order per project
|
||||||
await fetchModels();
|
await fetchModels();
|
||||||
|
|
||||||
// Guard: if token was invalidated during startup (401 → refresh fail → clearTokens),
|
// Guard: if token was invalidated during startup (401 → refresh fail → clearTokens),
|
||||||
|
|||||||
@@ -157,6 +157,9 @@ async function moveChatToProject(chatId, projectId) {
|
|||||||
try {
|
try {
|
||||||
await API.removeChannelFromProject(oldProjectId, chatId);
|
await API.removeChannelFromProject(oldProjectId, chatId);
|
||||||
chat.projectId = null;
|
chat.projectId = null;
|
||||||
|
// Remove from order tracking (v0.19.2)
|
||||||
|
const order = getProjectChannelOrder(oldProjectId);
|
||||||
|
setProjectChannelOrder(oldProjectId, order.filter(id => id !== chatId));
|
||||||
UI.renderChatList();
|
UI.renderChatList();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
UI.toast('Failed to remove from project', 'error');
|
UI.toast('Failed to remove from project', 'error');
|
||||||
@@ -165,6 +168,15 @@ async function moveChatToProject(chatId, projectId) {
|
|||||||
try {
|
try {
|
||||||
await API.addChannelToProject(projectId, chatId, 0);
|
await API.addChannelToProject(projectId, chatId, 0);
|
||||||
chat.projectId = projectId;
|
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();
|
UI.renderChatList();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
UI.toast('Failed to move to project', 'error');
|
UI.toast('Failed to move to project', 'error');
|
||||||
@@ -190,6 +202,21 @@ function showChatContextMenu(e, chatId) {
|
|||||||
// Move to project submenu
|
// Move to project submenu
|
||||||
let items = '';
|
let items = '';
|
||||||
if (chat.projectId) {
|
if (chat.projectId) {
|
||||||
|
// Reorder within project (v0.19.2)
|
||||||
|
const order = getProjectChannelOrder(chat.projectId);
|
||||||
|
const idx = order.indexOf(chatId);
|
||||||
|
if (idx > 0) {
|
||||||
|
items += `<button class="ctx-item" onclick="_moveChatInProject('${chat.projectId}','${chatId}',-1);dismissChatContextMenu()">
|
||||||
|
<span class="ctx-icon">↑</span> Move up
|
||||||
|
</button>`;
|
||||||
|
}
|
||||||
|
if (idx >= 0 && idx < order.length - 1) {
|
||||||
|
items += `<button class="ctx-item" onclick="_moveChatInProject('${chat.projectId}','${chatId}',1);dismissChatContextMenu()">
|
||||||
|
<span class="ctx-icon">↓</span> Move down
|
||||||
|
</button>`;
|
||||||
|
}
|
||||||
|
if (idx >= 0) items += '<div class="ctx-divider"></div>';
|
||||||
|
|
||||||
items += `<button class="ctx-item" onclick="moveChatToProject('${chatId}', null);dismissChatContextMenu()">
|
items += `<button class="ctx-item" onclick="moveChatToProject('${chatId}', null);dismissChatContextMenu()">
|
||||||
<span class="ctx-icon">↩</span> Remove from project
|
<span class="ctx-icon">↩</span> Remove from project
|
||||||
</button>`;
|
</button>`;
|
||||||
@@ -347,6 +374,13 @@ function _registerProjectPanel() {
|
|||||||
<button class="btn-small btn-primary" id="projectPromptSave" style="margin-top:6px">Save</button>
|
<button class="btn-small btn-primary" id="projectPromptSave" style="margin-top:6px">Save</button>
|
||||||
<span id="projectPromptStatus" class="project-panel-status"></span>
|
<span id="projectPromptStatus" class="project-panel-status"></span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="project-panel-section">
|
||||||
|
<label class="project-panel-label">Default Persona</label>
|
||||||
|
<select id="projectPersonaSelect" class="project-panel-select">
|
||||||
|
<option value="">None (use model selector)</option>
|
||||||
|
</select>
|
||||||
|
<div class="project-panel-hint">New chats in this project inherit the persona's model, parameters, and system prompt.</div>
|
||||||
|
</div>
|
||||||
<div class="project-panel-section">
|
<div class="project-panel-section">
|
||||||
<div class="project-panel-section-header">
|
<div class="project-panel-section-header">
|
||||||
<label class="project-panel-label">Knowledge Bases</label>
|
<label class="project-panel-label">Knowledge Bases</label>
|
||||||
@@ -358,6 +392,12 @@ function _registerProjectPanel() {
|
|||||||
<label class="project-panel-label">Notes</label>
|
<label class="project-panel-label">Notes</label>
|
||||||
<div id="projectNoteList" class="project-panel-list"></div>
|
<div id="projectNoteList" class="project-panel-list"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="project-panel-section project-panel-footer">
|
||||||
|
<label class="project-panel-checkbox">
|
||||||
|
<input type="checkbox" id="projectArchiveToggle">
|
||||||
|
<span>Archive this project</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
body.appendChild(el);
|
body.appendChild(el);
|
||||||
|
|
||||||
@@ -367,6 +407,12 @@ function _registerProjectPanel() {
|
|||||||
// Wire KB add button
|
// Wire KB add button
|
||||||
el.querySelector('#projectAddKB').addEventListener('click', _showKBPicker);
|
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', {
|
PanelRegistry.register('project', {
|
||||||
element: el,
|
element: el,
|
||||||
label: 'Project',
|
label: 'Project',
|
||||||
@@ -391,15 +437,21 @@ async function _loadProjectPanel(projectId) {
|
|||||||
if (!proj) return;
|
if (!proj) return;
|
||||||
|
|
||||||
// Load full project data from server (includes settings)
|
// Load full project data from server (includes settings)
|
||||||
|
let fullSettings = {};
|
||||||
try {
|
try {
|
||||||
const full = await API.getProject(projectId);
|
const full = await API.getProject(projectId);
|
||||||
const sp = (full.settings && full.settings.system_prompt) || '';
|
fullSettings = full.settings || {};
|
||||||
document.getElementById('projectSystemPrompt').value = sp;
|
document.getElementById('projectSystemPrompt').value = fullSettings.system_prompt || '';
|
||||||
document.getElementById('projectPromptStatus').textContent = '';
|
document.getElementById('projectPromptStatus').textContent = '';
|
||||||
|
// Archive toggle
|
||||||
|
document.getElementById('projectArchiveToggle').checked = !!full.is_archived;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
document.getElementById('projectSystemPrompt').value = '';
|
document.getElementById('projectSystemPrompt').value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Populate persona picker (v0.19.2)
|
||||||
|
await _renderPersonaPicker(fullSettings.persona_id || '');
|
||||||
|
|
||||||
// Load KBs
|
// Load KBs
|
||||||
await _renderProjectKBs(projectId);
|
await _renderProjectKBs(projectId);
|
||||||
|
|
||||||
@@ -537,3 +589,143 @@ async function _removeProjectNote(projectId, noteId) {
|
|||||||
UI.toast('Failed to remove note', 'error');
|
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 = '<option value="">None (use model selector)</option>';
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -302,9 +302,15 @@ const UI = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ── Project groups ──────────────────────
|
// ── Project groups ──────────────────────
|
||||||
if (projects.length > 0) {
|
const showArchived = App.showArchivedProjects || false;
|
||||||
projects.forEach(proj => {
|
const visibleProjects = showArchived ? projects : projects.filter(p => !p.isArchived);
|
||||||
const projChats = chats.filter(c => c.projectId === proj.id);
|
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
|
if (projChats.length === 0 && query) return; // hide empty projects during search
|
||||||
const isCollapsed = App.collapsedProjects[proj.id];
|
const isCollapsed = App.collapsedProjects[proj.id];
|
||||||
const colorDot = proj.color
|
const colorDot = proj.color
|
||||||
@@ -314,7 +320,9 @@ const UI = {
|
|||||||
|
|
||||||
const isActive = App.activeProjectId === proj.id;
|
const isActive = App.activeProjectId === proj.id;
|
||||||
|
|
||||||
html += `<div class="project-group${isActive ? ' active' : ''}"
|
const groupClasses = 'project-group' + (isActive ? ' active' : '') + (proj.isArchived ? ' archived' : '');
|
||||||
|
|
||||||
|
html += `<div class="${groupClasses}"
|
||||||
ondragover="onProjectDragOver(event)"
|
ondragover="onProjectDragOver(event)"
|
||||||
ondragleave="onProjectDragLeave(event)"
|
ondragleave="onProjectDragLeave(event)"
|
||||||
ondrop="onProjectDrop(event,'${proj.id}')">
|
ondrop="onProjectDrop(event,'${proj.id}')">
|
||||||
@@ -336,14 +344,25 @@ const UI = {
|
|||||||
}
|
}
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// "Show archived" toggle
|
||||||
|
if (hasArchived) {
|
||||||
|
html += `<button class="project-show-archived" onclick="App.showArchivedProjects=!App.showArchivedProjects;UI.renderChatList()">
|
||||||
|
${showArchived ? '▾ Hide archived' : '▸ Show archived (' + projects.filter(p => p.isArchived).length + ')'}
|
||||||
|
</button>`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Recent (unassigned) chats ───────────
|
// ── 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
|
// Only show "Recent" label if there are also projects
|
||||||
if (projects.length > 0) {
|
if (visibleProjects.length > 0) {
|
||||||
html += `<div class="recent-section"
|
html += `<div class="recent-section"
|
||||||
ondragover="onProjectDragOver(event)"
|
ondragover="onProjectDragOver(event)"
|
||||||
ondragleave="onProjectDragLeave(event)"
|
ondragleave="onProjectDragLeave(event)"
|
||||||
@@ -373,9 +392,7 @@ const UI = {
|
|||||||
|
|
||||||
const renderGroup = (label, items) => {
|
const renderGroup = (label, items) => {
|
||||||
if (items.length === 0) return;
|
if (items.length === 0) return;
|
||||||
// Only show time labels when there are no projects (preserve original look)
|
if (visibleProjects.length > 0) {
|
||||||
// or when inside the Recent section
|
|
||||||
if (projects.length > 0) {
|
|
||||||
html += `<div class="chat-group-label chat-time-label">${label}</div>`;
|
html += `<div class="chat-group-label chat-time-label">${label}</div>`;
|
||||||
} else {
|
} else {
|
||||||
html += `<div class="chat-group-label">${label}</div>`;
|
html += `<div class="chat-group-label">${label}</div>`;
|
||||||
@@ -388,10 +405,9 @@ const UI = {
|
|||||||
renderGroup('Previous 7 days', groups.week);
|
renderGroup('Previous 7 days', groups.week);
|
||||||
renderGroup('Older', groups.older);
|
renderGroup('Older', groups.older);
|
||||||
|
|
||||||
if (projects.length > 0 && recentChats.length > 0) {
|
if (visibleProjects.length > 0 && recentChats.length > 0) {
|
||||||
html += '</div>'; // close recent-section
|
html += '</div>'; // close recent-section
|
||||||
} else if (projects.length > 0 && recentChats.length === 0) {
|
} else if (visibleProjects.length > 0 && recentChats.length === 0) {
|
||||||
// Close the recent-section div we opened
|
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user