Changeset 0.19.1 (#83)

This commit is contained in:
2026-03-01 00:28:58 +00:00
parent 748f49bedd
commit 37a9b1a68e
12 changed files with 580 additions and 22 deletions

View File

@@ -2,6 +2,18 @@
All notable changes to Chat Switchboard. 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 ## [0.19.0] — 2026-02-28
### Added ### Added

View File

@@ -1 +1 @@
0.19.0 0.19.1

194
docs/DESIGN-0.19.1.md Normal file
View File

@@ -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: <name> [✕] │ ← 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.

View File

@@ -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) ── // ── KB hint (nudge LLM to use kb_search when KBs are active) ──
if kbHint := BuildKBHint(context.Background(), h.stores, channelID, userID, personaID); kbHint != "" { if kbHint := BuildKBHint(context.Background(), h.stores, channelID, userID, personaID); kbHint != "" {
messages = append(messages, providers.Message{ messages = append(messages, providers.Message{

View File

@@ -400,6 +400,7 @@ type ProjectPatch struct {
Color *string `json:"color,omitempty"` Color *string `json:"color,omitempty"`
Icon *string `json:"icon,omitempty"` Icon *string `json:"icon,omitempty"`
IsArchived *bool `json:"is_archived,omitempty"` IsArchived *bool `json:"is_archived,omitempty"`
Settings JSONMap `json:"settings,omitempty"`
} }
// ProjectChannel represents a channel's membership in a project. // ProjectChannel represents a channel's membership in a project.
@@ -417,6 +418,7 @@ type ProjectKB struct {
KBID string `json:"kb_id" db:"kb_id"` KBID string `json:"kb_id" db:"kb_id"`
AutoSearch bool `json:"auto_search" db:"auto_search"` AutoSearch bool `json:"auto_search" db:"auto_search"`
AddedAt string `json:"added_at" db:"added_at"` 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. // ProjectNote represents a note's association with a project.
@@ -424,6 +426,7 @@ type ProjectNote struct {
ProjectID string `json:"project_id" db:"project_id"` ProjectID string `json:"project_id" db:"project_id"`
NoteID string `json:"note_id" db:"note_id"` NoteID string `json:"note_id" db:"note_id"`
AddedAt string `json:"added_at" db:"added_at"` AddedAt string `json:"added_at" db:"added_at"`
Title string `json:"title,omitempty" db:"title"` // enriched via JOIN
} }
// ========================================= // =========================================

View File

@@ -69,6 +69,18 @@ func (s *ProjectStore) Update(ctx context.Context, id string, patch models.Proje
if patch.IsArchived != nil { if patch.IsArchived != nil {
b.Set("is_archived", *patch.IsArchived) 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() { if !b.HasSets() {
return nil 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) { func (s *ProjectStore) ListKBs(ctx context.Context, projectID string) ([]models.ProjectKB, error) {
rows, err := DB.QueryContext(ctx, ` rows, err := DB.QueryContext(ctx, `
SELECT project_id, kb_id, auto_search, added_at SELECT pk.project_id, pk.kb_id, pk.auto_search, pk.added_at,
FROM project_knowledge_bases COALESCE(kb.name, '') AS name
WHERE project_id = $1 FROM project_knowledge_bases pk
ORDER BY added_at`, projectID) 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 { if err != nil {
return nil, err return nil, err
} }
@@ -266,7 +280,7 @@ func (s *ProjectStore) ListKBs(ctx context.Context, projectID string) ([]models.
var result []models.ProjectKB var result []models.ProjectKB
for rows.Next() { for rows.Next() {
var pkb models.ProjectKB 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 return nil, err
} }
result = append(result, pkb) 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) { func (s *ProjectStore) ListNotes(ctx context.Context, projectID string) ([]models.ProjectNote, error) {
rows, err := DB.QueryContext(ctx, ` rows, err := DB.QueryContext(ctx, `
SELECT project_id, note_id, added_at SELECT pn.project_id, pn.note_id, pn.added_at,
FROM project_notes COALESCE(n.title, '') AS title
WHERE project_id = $1 FROM project_notes pn
ORDER BY added_at`, projectID) LEFT JOIN notes n ON n.id = pn.note_id
WHERE pn.project_id = $1
ORDER BY pn.added_at`, projectID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -332,7 +348,7 @@ func (s *ProjectStore) ListNotes(ctx context.Context, projectID string) ([]model
var result []models.ProjectNote var result []models.ProjectNote
for rows.Next() { for rows.Next() {
var pn models.ProjectNote 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 return nil, err
} }
result = append(result, pn) result = append(result, pn)

View File

@@ -85,6 +85,18 @@ func (s *ProjectStore) Update(ctx context.Context, id string, patch models.Proje
if patch.IsArchived != nil { if patch.IsArchived != nil {
add("is_archived", boolToInt(*patch.IsArchived)) 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 { if len(sets) == 0 {
return nil 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) { func (s *ProjectStore) ListKBs(ctx context.Context, projectID string) ([]models.ProjectKB, error) {
rows, err := DB.QueryContext(ctx, ` rows, err := DB.QueryContext(ctx, `
SELECT project_id, kb_id, auto_search, added_at SELECT pk.project_id, pk.kb_id, pk.auto_search, pk.added_at,
FROM project_knowledge_bases COALESCE(kb.name, '') AS name
WHERE project_id = ? FROM project_knowledge_bases pk
ORDER BY added_at`, projectID) LEFT JOIN knowledge_bases kb ON kb.id = pk.kb_id
WHERE pk.project_id = ?
ORDER BY pk.added_at`, projectID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -290,7 +304,7 @@ func (s *ProjectStore) ListKBs(ctx context.Context, projectID string) ([]models.
for rows.Next() { for rows.Next() {
var pkb models.ProjectKB var pkb models.ProjectKB
var autoSearch int 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 return nil, err
} }
pkb.AutoSearch = autoSearch != 0 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) { func (s *ProjectStore) ListNotes(ctx context.Context, projectID string) ([]models.ProjectNote, error) {
rows, err := DB.QueryContext(ctx, ` rows, err := DB.QueryContext(ctx, `
SELECT project_id, note_id, added_at SELECT pn.project_id, pn.note_id, pn.added_at,
FROM project_notes COALESCE(n.title, '') AS title
WHERE project_id = ? FROM project_notes pn
ORDER BY added_at`, projectID) LEFT JOIN notes n ON n.id = pn.note_id
WHERE pn.project_id = ?
ORDER BY pn.added_at`, projectID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -356,7 +372,7 @@ func (s *ProjectStore) ListNotes(ctx context.Context, projectID string) ([]model
var result []models.ProjectNote var result []models.ProjectNote
for rows.Next() { for rows.Next() {
var pn models.ProjectNote 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 return nil, err
} }
result = append(result, pn) result = append(result, pn)

View File

@@ -2418,10 +2418,16 @@ select option { background: var(--bg-surface); color: var(--text); }
font-style: italic; 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 .project-group { display: none; }
.sidebar.collapsed .recent-section .chat-group-label { display: none; } .sidebar.collapsed .recent-section .chat-group-label { display: none; }
/* Recent section drop target */ /* Recent section drop target */
.recent-section { min-height: 40px; }
.recent-section.drag-over { .recent-section.drag-over {
background: color-mix(in srgb, var(--accent) 8%, transparent); background: color-mix(in srgb, var(--accent) 8%, transparent);
border-radius: var(--radius); border-radius: var(--radius);
@@ -2454,3 +2460,33 @@ select option { background: var(--bg-surface); color: var(--text); }
/* Time labels inside project-grouped sidebar */ /* Time labels inside project-grouped sidebar */
.chat-time-label { padding-left: 12px; font-size: 10px; } .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; }

View File

@@ -19,6 +19,7 @@ const App = {
storageConfigured: false, storageConfigured: false,
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
// 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) {
@@ -427,6 +428,7 @@ function initListeners() {
_initAttachmentListeners(); // from attachments.js _initAttachmentListeners(); // from attachments.js
_registerPreviewPanel(); // from ui-format.js — register preview with PanelRegistry _registerPreviewPanel(); // from ui-format.js — register preview with PanelRegistry
_registerNotesPanel(); // from notes.js — register notes 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 _initGlobalKeyboard(); // local: Escape, Ctrl+K, Ctrl+\, resize
_initSidePanelResize(); // from panels.js _initSidePanelResize(); // from panels.js
_initDualDivider(); // from panels.js — dual-view split drag _initDualDivider(); // from panels.js — dual-view split drag

View File

@@ -469,6 +469,13 @@ async function sendMessage() {
const title = text.slice(0, 50) + (text.length > 50 ? '...' : ''); const title = text.slice(0, 50) + (text.length > 50 ? '...' : '');
const resp = await API.createChannel(title, model, App.settings.systemPrompt, 'direct'); 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 || {} }; 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.chats.unshift(chat);
App.currentChatId = chat.id; App.currentChatId = chat.id;
UI.renderChatList(); UI.renderChatList();

View File

@@ -25,6 +25,14 @@ async function loadProjects() {
const saved = JSON.parse(localStorage.getItem('cs-collapsed-projects') || '{}'); const saved = JSON.parse(localStorage.getItem('cs-collapsed-projects') || '{}');
App.collapsedProjects = saved; App.collapsedProjects = saved;
} catch (_) {} } 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) { } catch (e) {
console.warn('Failed to load projects:', e.message); console.warn('Failed to load projects:', e.message);
App.projects = []; App.projects = [];
@@ -87,6 +95,11 @@ async function deleteProject(projectId) {
App.projects = App.projects.filter(p => p.id !== projectId); App.projects = App.projects.filter(p => p.id !== projectId);
// Unassign chats client-side // Unassign chats client-side
App.chats.forEach(c => { if (c.projectId === projectId) c.projectId = null; }); 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.renderChatList();
UI.toast('Project deleted', 'success'); UI.toast('Project deleted', 'success');
} catch (e) { } catch (e) {
@@ -118,6 +131,19 @@ async function setProjectColor(projectId) {
input.click(); 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 ──────────────────── // ── Move Chat to Project ────────────────────
async function moveChatToProject(chatId, projectId) { async function moveChatToProject(chatId, projectId) {
@@ -265,9 +291,19 @@ function showProjectMenu(e, projectId) {
e.stopPropagation(); e.stopPropagation();
dismissChatContextMenu(); dismissChatContextMenu();
const isActive = App.activeProjectId === projectId;
const activeLabel = isActive ? 'Unpin project' : 'Pin as active';
const menu = document.createElement('div'); const menu = document.createElement('div');
menu.className = 'project-ctx-menu'; menu.className = 'project-ctx-menu';
menu.innerHTML = ` menu.innerHTML = `
<button class="ctx-item" onclick="toggleActiveProject('${projectId}');dismissChatContextMenu()">
<span class="ctx-icon">${isActive ? '📌' : '📌'}</span> ${activeLabel}
</button>
<button class="ctx-item" onclick="openProjectPanel('${projectId}');dismissChatContextMenu()">
<span class="ctx-icon">⚙</span> Project settings
</button>
<div class="ctx-divider"></div>
<button class="ctx-item" onclick="renameProject('${projectId}');dismissChatContextMenu()"> <button class="ctx-item" onclick="renameProject('${projectId}');dismissChatContextMenu()">
<span class="ctx-icon">✏</span> Rename <span class="ctx-icon">✏</span> Rename
</button> </button>
@@ -286,3 +322,218 @@ function showProjectMenu(e, projectId) {
_ctxMenu = menu; _ctxMenu = menu;
setTimeout(() => document.addEventListener('click', dismissChatContextMenu, { once: true }), 0); 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 = `
<div class="project-panel" id="projectPanelInner">
<div class="project-panel-section">
<label class="project-panel-label">System Prompt</label>
<textarea id="projectSystemPrompt" class="project-panel-textarea"
placeholder="Instructions for all chats in this project..." rows="6"></textarea>
<button class="btn-small btn-primary" id="projectPromptSave" style="margin-top:6px">Save</button>
<span id="projectPromptStatus" class="project-panel-status"></span>
</div>
<div class="project-panel-section">
<div class="project-panel-section-header">
<label class="project-panel-label">Knowledge Bases</label>
<button class="btn-small" id="projectAddKB" title="Add KB">+ Add</button>
</div>
<div id="projectKBList" class="project-panel-list"></div>
</div>
<div class="project-panel-section">
<label class="project-panel-label">Notes</label>
<div id="projectNoteList" class="project-panel-list"></div>
</div>
</div>`;
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 = '<div class="project-panel-empty">No KBs bound to this project</div>';
return;
}
list.innerHTML = kbs.map(kb => `
<div class="project-panel-item">
<span class="project-panel-item-name">${esc(kb.name || kb.kb_id)}</span>
<button class="project-panel-item-remove" onclick="_removeProjectKB('${projectId}','${kb.kb_id}')" title="Remove">✕</button>
</div>`).join('');
} catch (_) {
list.innerHTML = '<div class="project-panel-empty">Failed to load KBs</div>';
}
}
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 => `
<button class="ctx-item" onclick="_addProjectKB('${_projectPanelId}','${kb.id}');dismissChatContextMenu()">
${esc(kb.name)}
</button>`).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 = '<div class="project-panel-empty">No notes in this project</div>';
return;
}
list.innerHTML = notes.map(n => `
<div class="project-panel-item">
<span class="project-panel-item-name">${esc(n.title || n.note_id)}</span>
<button class="project-panel-item-remove" onclick="_removeProjectNote('${projectId}','${n.note_id}')" title="Remove">✕</button>
</div>`).join('');
} catch (_) {
list.innerHTML = '<div class="project-panel-empty">Failed to load notes</div>';
}
}
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');
}
}

View File

@@ -312,7 +312,9 @@ const UI = {
: '<span class="project-dot"></span>'; : '<span class="project-dot"></span>';
const arrow = isCollapsed ? '▸' : '▾'; const arrow = isCollapsed ? '▸' : '▾';
html += `<div class="project-group" const isActive = App.activeProjectId === proj.id;
html += `<div class="project-group${isActive ? ' active' : ''}"
ondragover="onProjectDragOver(event)" ondragover="onProjectDragOver(event)"
ondragleave="onProjectDragLeave(event)" ondragleave="onProjectDragLeave(event)"
ondrop="onProjectDrop(event,'${proj.id}')"> ondrop="onProjectDrop(event,'${proj.id}')">
@@ -320,6 +322,7 @@ const UI = {
<span class="project-arrow">${arrow}</span> <span class="project-arrow">${arrow}</span>
${colorDot} ${colorDot}
<span class="project-name">${esc(proj.name)}</span> <span class="project-name">${esc(proj.name)}</span>
${isActive ? '<span class="project-pin" title="Active project">📌</span>' : ''}
<span class="project-count">${projChats.length}</span> <span class="project-count">${projChats.length}</span>
<button class="project-menu-btn" onclick="event.stopPropagation();showProjectMenu(event,'${proj.id}')" title="Project options">⋯</button> <button class="project-menu-btn" onclick="event.stopPropagation();showProjectMenu(event,'${proj.id}')" title="Project options">⋯</button>
</div>`; </div>`;
@@ -346,6 +349,9 @@ const UI = {
ondragleave="onProjectDragLeave(event)" ondragleave="onProjectDragLeave(event)"
ondrop="onRecentDrop(event)"> ondrop="onRecentDrop(event)">
<div class="chat-group-label" style="padding-top:8px">Recent</div>`; <div class="chat-group-label" style="padding-top:8px">Recent</div>`;
if (recentChats.length === 0) {
html += '<div class="project-empty">Drop chats here to unassign</div>';
}
} }
} }