diff --git a/CHANGELOG.md b/CHANGELOG.md index c9294ce..46683e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ All notable changes to Chat Switchboard. +## [0.19.1] — 2026-02-28 + +### Added +- **Active Project**: Pin a project as active; new chats auto-assign to it. Persists across reloads via localStorage. Visual indicator (📌 + accent border) in sidebar. +- **Project System Prompt**: Per-project instructions stored in `projects.settings` JSONB. Injected between persona/channel prompt and KB hint during completion. Merge semantics on update (preserves other settings keys). +- **Project Detail Panel**: Side panel (via PanelRegistry) for managing project system prompt, KB bindings, and notes. Accessible from project ⋯ menu → "Project settings". +- Enriched `ListKBs` and `ListNotes` responses with JOIN-sourced `name`/`title` fields for display in the project panel. + +### Changed +- `ProjectPatch` model now accepts `settings` field for partial settings merge. +- Project context menu expanded: "Pin as active", "Project settings", plus existing rename/color/delete. + ## [0.19.0] — 2026-02-28 ### Added diff --git a/VERSION b/VERSION index 3f46c4d..4559101 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.19.0 \ No newline at end of file +0.19.1 \ No newline at end of file diff --git a/docs/DESIGN-0.19.1.md b/docs/DESIGN-0.19.1.md new file mode 100644 index 0000000..fb6e575 --- /dev/null +++ b/docs/DESIGN-0.19.1.md @@ -0,0 +1,194 @@ +# v0.19.1 — Active Projects, Project System Prompt, Project Detail Panel + +**Status:** Implementation +**Depends on:** v0.19.0 Projects/Workspaces + +--- + +## Overview + +Three tightly-scoped improvements that make projects genuinely usable +for daily workflow rather than just organizational containers: + +1. **Active Project** — one-click project activation so new chats + auto-assign without drag-drop. +2. **Project System Prompt** — per-project instructions injected into + every chat within the project at completion time. +3. **Project Detail Panel** — side panel UI for managing a project's + system prompt, KB bindings, and notes. + +--- + +## 1. Active Project + +### State + +| Location | Key | Type | +|----------|-----|------| +| `App.activeProjectId` | runtime | `string \| null` | +| `localStorage` | `cs-active-project` | `string \| null` | + +### Behavior + +- Click project header's **pin icon** (or "Set as active" in ⋯ menu) + to activate. +- Active project gets a visual indicator: left accent border + subtle + background tint using the project's color. +- Creating a new chat (via `sendMessage` when `!App.currentChatId`) + auto-calls `addChannelToProject(activeProjectId, newChannelId)` and + sets `chat.projectId` client-side. +- Only one project can be active at a time. Clicking the pin on an + already-active project deactivates it. +- Active state persists across page reloads via `localStorage`. +- If the active project is deleted, `activeProjectId` is cleared. + +### Files Changed + +| File | Change | +|------|--------| +| `src/js/app.js` | Add `activeProjectId: null` to App, restore from localStorage on init | +| `src/js/projects-ui.js` | `setActiveProject(id)`, `clearActiveProject()`, menu items, pin icon | +| `src/js/chat.js` | Auto-assign in `sendMessage` after channel creation | +| `src/js/ui-core.js` | `.active` class on project group in `renderChatList` | +| `src/css/styles.css` | `.project-group.active` styling | + +--- + +## 2. Project System Prompt + +### Injection Order + +``` +1. Admin system prompt (global policy — no opt-out) +2. Persona OR channel prompt (role/character definition) +3. ▸ PROJECT system prompt (project-specific context) ← NEW +4. KB hint (available knowledge bases) +5. Memory hint (user facts from v0.18.0) +6. Summary / conversation (message history) +``` + +The project prompt sits between the role definition and the KB hint. +This lets a persona define "you are a coding assistant" while the +project adds "this project is the React dashboard for client X." + +### Backend Flow (completion.go) + +In `loadConversation`, after the persona/channel system prompt block: + +```go +// ── Project system prompt (v0.19.1) ── +if h.stores.Projects != nil { + projID, _ := h.stores.Projects.GetProjectIDForChannel(ctx, channelID) + if projID != "" { + proj, err := h.stores.Projects.GetByID(ctx, projID) + if err == nil { + if sp, ok := proj.Settings["system_prompt"].(string); ok && sp != "" { + messages = append(messages, providers.Message{ + Role: "system", + Content: sp, + }) + } + } + } +} +``` + +### Storage + +Uses existing `projects.settings` JSONB column. No migration needed. + +```json +{ + "system_prompt": "You are helping build the Chat Switchboard project..." +} +``` + +### Files Changed + +| File | Change | +|------|--------| +| `server/handlers/completion.go` | Inject project system prompt in `loadConversation` | + +--- + +## 3. Project Detail Panel + +### Registration + +Follows the same `PanelRegistry.register()` pattern as notes and +preview panels. Registered during app init via `_registerProjectPanel()`. + +### Panel Layout + +``` +┌─────────────────────────────┐ +│ Project: [✕] │ ← header (editable name) +├─────────────────────────────┤ +│ System Prompt │ +│ ┌─────────────────────────┐ │ +│ │ textarea (auto-resize) │ │ +│ └─────────────────────────┘ │ +│ [Save] │ +├─────────────────────────────┤ +│ Knowledge Bases [+] │ +│ ┌─────────────────────────┐ │ +│ │ • KB Alpha [✕] │ │ +│ │ • KB Beta [✕] │ │ +│ └─────────────────────────┘ │ +│ (empty: "No KBs bound") │ +├─────────────────────────────┤ +│ Notes │ +│ ┌─────────────────────────┐ │ +│ │ • Meeting notes [✕] │ │ +│ │ • Design spec [✕] │ │ +│ └─────────────────────────┘ │ +│ (empty: "No notes") │ +├─────────────────────────────┤ +│ Color [●] │ +│ Archived [ ] │ +└─────────────────────────────┘ +``` + +### Interactions + +- **System prompt:** Textarea with debounced save (1s) or explicit Save + button. Writes to `PUT /projects/:id` with + `settings: { system_prompt: "..." }`. +- **KB add:** [+] opens a dropdown of available KBs (from + `GET /knowledge-bases`), filtered to exclude already-bound ones. + Calls `POST /projects/:id/knowledge-bases`. +- **KB remove:** [✕] calls `DELETE /projects/:id/knowledge-bases/:kbId`. +- **Notes:** Read-only list with [✕] to unbind. Notes are auto-associated + from channel creation (v0.19.0). +- **Color:** Reuses the invisible color picker pattern from + `setProjectColor()`. +- **Open trigger:** Click project name in sidebar header, or + "Project details" in ⋯ menu. + +### Files Changed + +| File | Change | +|------|--------| +| `src/index.html` | Add `#sidePanelProject` div inside `#sidePanelBody` | +| `src/js/projects-ui.js` | `openProjectPanel(id)`, `_registerProjectPanel()`, panel rendering, KB/note management | +| `src/js/app.js` | Call `_registerProjectPanel()` in init | +| `src/css/styles.css` | `.project-panel-*` styles | + +--- + +## Files Summary + +| File | Phase | Type | +|------|-------|------| +| `server/handlers/completion.go` | 2 | Modified | +| `src/js/app.js` | 1,3 | Modified | +| `src/js/projects-ui.js` | 1,3 | Modified | +| `src/js/chat.js` | 1 | Modified | +| `src/js/ui-core.js` | 1 | Modified | +| `src/css/styles.css` | 1,3 | Modified | +| `src/index.html` | 3 | Modified | +| `VERSION` | — | Modified (0.19.0 → 0.19.1) | +| `CHANGELOG.md` | — | Modified | + +No new files. No migrations. Backend change is a single injection +point in completion.go. diff --git a/server/handlers/completion.go b/server/handlers/completion.go index de77664..3925c42 100644 --- a/server/handlers/completion.go +++ b/server/handlers/completion.go @@ -791,6 +791,21 @@ func (h *CompletionHandler) loadConversation(channelID, userID, presetSystemProm }) } + // ── Project system prompt (v0.19.1) ── + if 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 sp, ok := proj.Settings["system_prompt"].(string); ok && sp != "" { + messages = append(messages, providers.Message{ + Role: "system", + Content: sp, + }) + } + } + } + } + // ── KB hint (nudge LLM to use kb_search when KBs are active) ── if kbHint := BuildKBHint(context.Background(), h.stores, channelID, userID, personaID); kbHint != "" { messages = append(messages, providers.Message{ diff --git a/server/models/models.go b/server/models/models.go index e9ae149..6bef79b 100644 --- a/server/models/models.go +++ b/server/models/models.go @@ -400,6 +400,7 @@ type ProjectPatch struct { Color *string `json:"color,omitempty"` Icon *string `json:"icon,omitempty"` IsArchived *bool `json:"is_archived,omitempty"` + Settings JSONMap `json:"settings,omitempty"` } // ProjectChannel represents a channel's membership in a project. @@ -417,6 +418,7 @@ type ProjectKB struct { KBID string `json:"kb_id" db:"kb_id"` AutoSearch bool `json:"auto_search" db:"auto_search"` AddedAt string `json:"added_at" db:"added_at"` + Name string `json:"name,omitempty" db:"name"` // enriched via JOIN } // ProjectNote represents a note's association with a project. @@ -424,6 +426,7 @@ type ProjectNote struct { ProjectID string `json:"project_id" db:"project_id"` NoteID string `json:"note_id" db:"note_id"` AddedAt string `json:"added_at" db:"added_at"` + Title string `json:"title,omitempty" db:"title"` // enriched via JOIN } // ========================================= diff --git a/server/store/postgres/project.go b/server/store/postgres/project.go index 303d858..bea24da 100644 --- a/server/store/postgres/project.go +++ b/server/store/postgres/project.go @@ -69,6 +69,18 @@ func (s *ProjectStore) Update(ctx context.Context, id string, patch models.Proje if patch.IsArchived != nil { b.Set("is_archived", *patch.IsArchived) } + if len(patch.Settings) > 0 { + // Merge: read existing settings, overlay with patch values, write back. + var existing models.JSONMap + _ = DB.QueryRowContext(ctx, "SELECT settings FROM projects WHERE id = $1", id).Scan(&existing) + if existing == nil { + existing = make(models.JSONMap) + } + for k, v := range patch.Settings { + existing[k] = v + } + b.Set("settings", ToJSON(existing)) + } if !b.HasSets() { return nil } @@ -254,10 +266,12 @@ func (s *ProjectStore) RemoveKB(ctx context.Context, projectID, kbID string) err func (s *ProjectStore) ListKBs(ctx context.Context, projectID string) ([]models.ProjectKB, error) { rows, err := DB.QueryContext(ctx, ` - SELECT project_id, kb_id, auto_search, added_at - FROM project_knowledge_bases - WHERE project_id = $1 - ORDER BY added_at`, projectID) + SELECT pk.project_id, pk.kb_id, pk.auto_search, pk.added_at, + COALESCE(kb.name, '') AS name + FROM project_knowledge_bases pk + LEFT JOIN knowledge_bases kb ON kb.id = pk.kb_id + WHERE pk.project_id = $1 + ORDER BY pk.added_at`, projectID) if err != nil { return nil, err } @@ -266,7 +280,7 @@ func (s *ProjectStore) ListKBs(ctx context.Context, projectID string) ([]models. var result []models.ProjectKB for rows.Next() { var pkb models.ProjectKB - if err := rows.Scan(&pkb.ProjectID, &pkb.KBID, &pkb.AutoSearch, &pkb.AddedAt); err != nil { + if err := rows.Scan(&pkb.ProjectID, &pkb.KBID, &pkb.AutoSearch, &pkb.AddedAt, &pkb.Name); err != nil { return nil, err } result = append(result, pkb) @@ -320,10 +334,12 @@ func (s *ProjectStore) RemoveNote(ctx context.Context, projectID, noteID string) func (s *ProjectStore) ListNotes(ctx context.Context, projectID string) ([]models.ProjectNote, error) { rows, err := DB.QueryContext(ctx, ` - SELECT project_id, note_id, added_at - FROM project_notes - WHERE project_id = $1 - ORDER BY added_at`, projectID) + SELECT pn.project_id, pn.note_id, pn.added_at, + COALESCE(n.title, '') AS title + FROM project_notes pn + LEFT JOIN notes n ON n.id = pn.note_id + WHERE pn.project_id = $1 + ORDER BY pn.added_at`, projectID) if err != nil { return nil, err } @@ -332,7 +348,7 @@ func (s *ProjectStore) ListNotes(ctx context.Context, projectID string) ([]model var result []models.ProjectNote for rows.Next() { var pn models.ProjectNote - if err := rows.Scan(&pn.ProjectID, &pn.NoteID, &pn.AddedAt); err != nil { + if err := rows.Scan(&pn.ProjectID, &pn.NoteID, &pn.AddedAt, &pn.Title); err != nil { return nil, err } result = append(result, pn) diff --git a/server/store/sqlite/project.go b/server/store/sqlite/project.go index 3ca320a..5cd622c 100644 --- a/server/store/sqlite/project.go +++ b/server/store/sqlite/project.go @@ -85,6 +85,18 @@ func (s *ProjectStore) Update(ctx context.Context, id string, patch models.Proje if patch.IsArchived != nil { add("is_archived", boolToInt(*patch.IsArchived)) } + if len(patch.Settings) > 0 { + // Merge: read existing settings, overlay with patch values, write back. + var existing models.JSONMap + _ = DB.QueryRowContext(ctx, "SELECT settings FROM projects WHERE id = ?", id).Scan(&existing) + if existing == nil { + existing = make(models.JSONMap) + } + for k, v := range patch.Settings { + existing[k] = v + } + add("settings", ToJSON(existing)) + } if len(sets) == 0 { return nil } @@ -277,10 +289,12 @@ func (s *ProjectStore) RemoveKB(ctx context.Context, projectID, kbID string) err func (s *ProjectStore) ListKBs(ctx context.Context, projectID string) ([]models.ProjectKB, error) { rows, err := DB.QueryContext(ctx, ` - SELECT project_id, kb_id, auto_search, added_at - FROM project_knowledge_bases - WHERE project_id = ? - ORDER BY added_at`, projectID) + SELECT pk.project_id, pk.kb_id, pk.auto_search, pk.added_at, + COALESCE(kb.name, '') AS name + FROM project_knowledge_bases pk + LEFT JOIN knowledge_bases kb ON kb.id = pk.kb_id + WHERE pk.project_id = ? + ORDER BY pk.added_at`, projectID) if err != nil { return nil, err } @@ -290,7 +304,7 @@ func (s *ProjectStore) ListKBs(ctx context.Context, projectID string) ([]models. for rows.Next() { var pkb models.ProjectKB var autoSearch int - if err := rows.Scan(&pkb.ProjectID, &pkb.KBID, &autoSearch, &pkb.AddedAt); err != nil { + if err := rows.Scan(&pkb.ProjectID, &pkb.KBID, &autoSearch, &pkb.AddedAt, &pkb.Name); err != nil { return nil, err } pkb.AutoSearch = autoSearch != 0 @@ -344,10 +358,12 @@ func (s *ProjectStore) RemoveNote(ctx context.Context, projectID, noteID string) func (s *ProjectStore) ListNotes(ctx context.Context, projectID string) ([]models.ProjectNote, error) { rows, err := DB.QueryContext(ctx, ` - SELECT project_id, note_id, added_at - FROM project_notes - WHERE project_id = ? - ORDER BY added_at`, projectID) + SELECT pn.project_id, pn.note_id, pn.added_at, + COALESCE(n.title, '') AS title + FROM project_notes pn + LEFT JOIN notes n ON n.id = pn.note_id + WHERE pn.project_id = ? + ORDER BY pn.added_at`, projectID) if err != nil { return nil, err } @@ -356,7 +372,7 @@ func (s *ProjectStore) ListNotes(ctx context.Context, projectID string) ([]model var result []models.ProjectNote for rows.Next() { var pn models.ProjectNote - if err := rows.Scan(&pn.ProjectID, &pn.NoteID, &pn.AddedAt); err != nil { + if err := rows.Scan(&pn.ProjectID, &pn.NoteID, &pn.AddedAt, &pn.Title); err != nil { return nil, err } result = append(result, pn) diff --git a/src/css/styles.css b/src/css/styles.css index 6345f08..15e0f30 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -2418,10 +2418,16 @@ select option { background: var(--bg-surface); color: var(--text); } font-style: italic; } +/* Active project indicator (v0.19.1) */ +.project-group.active { border-left: 2px solid var(--accent); } +.project-group.active .project-header { background: color-mix(in srgb, var(--accent) 6%, transparent); } +.project-pin { font-size: 10px; margin-left: 2px; opacity: 0.7; } + .sidebar.collapsed .project-group { display: none; } .sidebar.collapsed .recent-section .chat-group-label { display: none; } /* Recent section drop target */ +.recent-section { min-height: 40px; } .recent-section.drag-over { background: color-mix(in srgb, var(--accent) 8%, transparent); border-radius: var(--radius); @@ -2454,3 +2460,33 @@ select option { background: var(--bg-surface); color: var(--text); } /* Time labels inside project-grouped sidebar */ .chat-time-label { padding-left: 12px; font-size: 10px; } + +/* ── Project Detail Panel (v0.19.1) ───────── */ +.project-panel { padding: 16px; display: flex; flex-direction: column; gap: 16px; } +.project-panel-section { display: flex; flex-direction: column; gap: 6px; } +.project-panel-section-header { display: flex; justify-content: space-between; align-items: center; } +.project-panel-label { font-size: 12px; font-weight: 600; color: var(--text-2); text-transform: uppercase; letter-spacing: 0.5px; } +.project-panel-textarea { + width: 100%; resize: vertical; min-height: 80px; padding: 8px; + font-family: var(--font-mono, monospace); font-size: 13px; + background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); + color: var(--text); +} +.project-panel-textarea:focus { outline: none; border-color: var(--accent); } +.project-panel-status { font-size: 11px; margin-left: 8px; } +.project-panel-status.success { color: var(--success, #22c55e); } +.project-panel-status.error { color: var(--danger, #ef4444); } +.project-panel-list { display: flex; flex-direction: column; gap: 2px; } +.project-panel-item { + display: flex; align-items: center; justify-content: space-between; + padding: 6px 8px; border-radius: var(--radius); + background: var(--bg); border: 1px solid var(--border); + font-size: 13px; +} +.project-panel-item-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.project-panel-item-remove { + background: none; border: none; cursor: pointer; color: var(--text-3); + font-size: 14px; padding: 2px 4px; border-radius: 4px; flex-shrink: 0; +} +.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; } diff --git a/src/js/app.js b/src/js/app.js index 2b20f98..0f20984 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -19,6 +19,7 @@ const App = { storageConfigured: false, 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 // Find model by composite ID, with fallback to bare model_id match findModel(id) { @@ -427,6 +428,7 @@ function initListeners() { _initAttachmentListeners(); // from attachments.js _registerPreviewPanel(); // from ui-format.js — register preview with PanelRegistry _registerNotesPanel(); // from notes.js — register notes with PanelRegistry + _registerProjectPanel(); // from projects-ui.js — register project detail panel _initGlobalKeyboard(); // local: Escape, Ctrl+K, Ctrl+\, resize _initSidePanelResize(); // from panels.js _initDualDivider(); // from panels.js — dual-view split drag diff --git a/src/js/chat.js b/src/js/chat.js index 18980c1..2786b66 100644 --- a/src/js/chat.js +++ b/src/js/chat.js @@ -469,6 +469,13 @@ async function sendMessage() { const title = text.slice(0, 50) + (text.length > 50 ? '...' : ''); const resp = await API.createChannel(title, model, App.settings.systemPrompt, 'direct'); const chat = { id: resp.id, title: resp.title, type: 'direct', model, messages: [], messageCount: 0, projectId: resp.project_id || null, updatedAt: resp.updated_at, settings: resp.settings || {} }; + // Auto-assign to active project (v0.19.1) + if (App.activeProjectId && !chat.projectId) { + try { + await API.addChannelToProject(App.activeProjectId, chat.id, 0); + chat.projectId = App.activeProjectId; + } catch (_) { /* best effort — chat still works without project */ } + } App.chats.unshift(chat); App.currentChatId = chat.id; UI.renderChatList(); diff --git a/src/js/projects-ui.js b/src/js/projects-ui.js index 587e0e4..c9a9170 100644 --- a/src/js/projects-ui.js +++ b/src/js/projects-ui.js @@ -25,6 +25,14 @@ async function loadProjects() { const saved = JSON.parse(localStorage.getItem('cs-collapsed-projects') || '{}'); App.collapsedProjects = saved; } catch (_) {} + // Restore active project (v0.19.1) + const savedActive = localStorage.getItem('cs-active-project'); + if (savedActive && App.projects.some(p => p.id === savedActive)) { + App.activeProjectId = savedActive; + } else { + App.activeProjectId = null; + localStorage.removeItem('cs-active-project'); + } } catch (e) { console.warn('Failed to load projects:', e.message); App.projects = []; @@ -87,6 +95,11 @@ async function deleteProject(projectId) { App.projects = App.projects.filter(p => p.id !== projectId); // Unassign chats client-side App.chats.forEach(c => { if (c.projectId === projectId) c.projectId = null; }); + // Clear active if this was the active project (v0.19.1) + if (App.activeProjectId === projectId) { + App.activeProjectId = null; + localStorage.removeItem('cs-active-project'); + } UI.renderChatList(); UI.toast('Project deleted', 'success'); } catch (e) { @@ -118,6 +131,19 @@ async function setProjectColor(projectId) { input.click(); } +// ── Active Project (v0.19.1) ──────────────── + +function toggleActiveProject(projectId) { + if (App.activeProjectId === projectId) { + App.activeProjectId = null; + localStorage.removeItem('cs-active-project'); + } else { + App.activeProjectId = projectId; + localStorage.setItem('cs-active-project', projectId); + } + UI.renderChatList(); +} + // ── Move Chat to Project ──────────────────── async function moveChatToProject(chatId, projectId) { @@ -265,9 +291,19 @@ function showProjectMenu(e, projectId) { e.stopPropagation(); dismissChatContextMenu(); + const isActive = App.activeProjectId === projectId; + const activeLabel = isActive ? 'Unpin project' : 'Pin as active'; + const menu = document.createElement('div'); menu.className = 'project-ctx-menu'; menu.innerHTML = ` + + +
@@ -286,3 +322,218 @@ function showProjectMenu(e, projectId) { _ctxMenu = menu; setTimeout(() => document.addEventListener('click', dismissChatContextMenu, { once: true }), 0); } + +// ═══════════════════════════════════════════ +// PROJECT DETAIL PANEL (v0.19.1) +// ═══════════════════════════════════════════ + +let _projectPanelId = null; // currently-displayed project ID + +function _registerProjectPanel() { + const body = document.getElementById('sidePanelBody'); + if (!body || typeof PanelRegistry === 'undefined') return; + + // Create the panel element dynamically + const el = document.createElement('div'); + el.className = 'side-panel-page'; + el.id = 'sidePanelProject'; + el.style.display = 'none'; + el.innerHTML = ` +
+
+ + + + +
+
+
+ + +
+
+
+
+ +
+
+
`; + body.appendChild(el); + + // Wire save button + el.querySelector('#projectPromptSave').addEventListener('click', _saveProjectPrompt); + + // Wire KB add button + el.querySelector('#projectAddKB').addEventListener('click', _showKBPicker); + + PanelRegistry.register('project', { + element: el, + label: 'Project', + onOpen() { if (_projectPanelId) _loadProjectPanel(_projectPanelId); }, + }); +} + +async function openProjectPanel(projectId) { + _projectPanelId = projectId; + const proj = App.projects.find(p => p.id === projectId); + if (typeof PanelRegistry !== 'undefined') { + PanelRegistry.open('project'); + // Update label to show project name + const label = document.getElementById('sidePanelLabel'); + if (label && proj) label.textContent = proj.name; + } + await _loadProjectPanel(projectId); +} + +async function _loadProjectPanel(projectId) { + const proj = App.projects.find(p => p.id === projectId); + if (!proj) return; + + // Load full project data from server (includes settings) + try { + const full = await API.getProject(projectId); + const sp = (full.settings && full.settings.system_prompt) || ''; + document.getElementById('projectSystemPrompt').value = sp; + document.getElementById('projectPromptStatus').textContent = ''; + } catch (_) { + document.getElementById('projectSystemPrompt').value = ''; + } + + // Load KBs + await _renderProjectKBs(projectId); + + // Load notes + await _renderProjectNotes(projectId); +} + +async function _saveProjectPrompt() { + if (!_projectPanelId) return; + const sp = document.getElementById('projectSystemPrompt').value; + const status = document.getElementById('projectPromptStatus'); + try { + await API.updateProject(_projectPanelId, { + settings: { system_prompt: sp } + }); + status.textContent = '✓ Saved'; + status.className = 'project-panel-status success'; + setTimeout(() => { status.textContent = ''; }, 2000); + } catch (e) { + status.textContent = '✗ Failed'; + status.className = 'project-panel-status error'; + } +} + +async function _renderProjectKBs(projectId) { + const list = document.getElementById('projectKBList'); + if (!list) return; + try { + const resp = await API.listProjectKBs(projectId); + const kbs = resp.data || resp || []; + if (kbs.length === 0) { + list.innerHTML = '
No KBs bound to this project
'; + return; + } + list.innerHTML = kbs.map(kb => ` +
+ ${esc(kb.name || kb.kb_id)} + +
`).join(''); + } catch (_) { + list.innerHTML = '
Failed to load KBs
'; + } +} + +async function _removeProjectKB(projectId, kbId) { + try { + await API.removeKBFromProject(projectId, kbId); + await _renderProjectKBs(projectId); + UI.toast('KB removed from project', 'success'); + } catch (e) { + UI.toast('Failed to remove KB', 'error'); + } +} + +async function _showKBPicker() { + if (!_projectPanelId) return; + try { + // Get all KBs user can see + const allKBs = await API.listKnowledgeBases(); + const kbList = allKBs.data || allKBs || []; + if (kbList.length === 0) { + UI.toast('No knowledge bases available', 'info'); + return; + } + + // Get already-bound KBs + const boundResp = await API.listProjectKBs(_projectPanelId); + const boundIds = new Set((boundResp.data || boundResp || []).map(k => k.kb_id)); + + // Filter to unbound only + const available = kbList.filter(kb => !boundIds.has(kb.id)); + if (available.length === 0) { + UI.toast('All KBs already bound to this project', 'info'); + return; + } + + // Simple picker: use a dropdown menu + const btn = document.getElementById('projectAddKB'); + const rect = btn.getBoundingClientRect(); + dismissChatContextMenu(); + + const menu = document.createElement('div'); + menu.className = 'project-ctx-menu'; + menu.style.left = Math.min(rect.left, window.innerWidth - 200) + 'px'; + menu.style.top = (rect.bottom + 4) + 'px'; + menu.innerHTML = available.map(kb => ` + `).join(''); + + document.body.appendChild(menu); + _ctxMenu = menu; + setTimeout(() => document.addEventListener('click', dismissChatContextMenu, { once: true }), 0); + } catch (e) { + UI.toast('Failed to load KBs', 'error'); + } +} + +async function _addProjectKB(projectId, kbId) { + try { + await API.addKBToProject(projectId, kbId, true); + await _renderProjectKBs(projectId); + UI.toast('KB added to project', 'success'); + } catch (e) { + UI.toast('Failed to add KB', 'error'); + } +} + +async function _renderProjectNotes(projectId) { + const list = document.getElementById('projectNoteList'); + if (!list) return; + try { + const resp = await API.listProjectNotes(projectId); + const notes = resp.data || resp || []; + if (notes.length === 0) { + list.innerHTML = '
No notes in this project
'; + return; + } + list.innerHTML = notes.map(n => ` +
+ ${esc(n.title || n.note_id)} + +
`).join(''); + } catch (_) { + list.innerHTML = '
Failed to load notes
'; + } +} + +async function _removeProjectNote(projectId, noteId) { + try { + await API.removeNoteFromProject(projectId, noteId); + await _renderProjectNotes(projectId); + UI.toast('Note removed from project', 'success'); + } catch (e) { + UI.toast('Failed to remove note', 'error'); + } +} diff --git a/src/js/ui-core.js b/src/js/ui-core.js index 681c2e2..f69a2e4 100644 --- a/src/js/ui-core.js +++ b/src/js/ui-core.js @@ -312,7 +312,9 @@ const UI = { : ''; const arrow = isCollapsed ? '▸' : '▾'; - html += `
@@ -320,6 +322,7 @@ const UI = { ${arrow} ${colorDot} ${esc(proj.name)} + ${isActive ? '📌' : ''} ${projChats.length}
`; @@ -346,6 +349,9 @@ const UI = { ondragleave="onProjectDragLeave(event)" ondrop="onRecentDrop(event)">
Recent
`; + if (recentChats.length === 0) { + html += '
Drop chats here to unassign
'; + } } }