Changeset 0.19.1 (#83)
This commit is contained in:
12
CHANGELOG.md
12
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
|
||||
|
||||
194
docs/DESIGN-0.19.1.md
Normal file
194
docs/DESIGN-0.19.1.md
Normal 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.
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// =========================================
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 = `
|
||||
<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()">
|
||||
<span class="ctx-icon">✏</span> Rename
|
||||
</button>
|
||||
@@ -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 = `
|
||||
<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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,7 +312,9 @@ const UI = {
|
||||
: '<span class="project-dot"></span>';
|
||||
const arrow = isCollapsed ? '▸' : '▾';
|
||||
|
||||
html += `<div class="project-group"
|
||||
const isActive = App.activeProjectId === proj.id;
|
||||
|
||||
html += `<div class="project-group${isActive ? ' active' : ''}"
|
||||
ondragover="onProjectDragOver(event)"
|
||||
ondragleave="onProjectDragLeave(event)"
|
||||
ondrop="onProjectDrop(event,'${proj.id}')">
|
||||
@@ -320,6 +322,7 @@ const UI = {
|
||||
<span class="project-arrow">${arrow}</span>
|
||||
${colorDot}
|
||||
<span class="project-name">${esc(proj.name)}</span>
|
||||
${isActive ? '<span class="project-pin" title="Active project">📌</span>' : ''}
|
||||
<span class="project-count">${projChats.length}</span>
|
||||
<button class="project-menu-btn" onclick="event.stopPropagation();showProjectMenu(event,'${proj.id}')" title="Project options">⋯</button>
|
||||
</div>`;
|
||||
@@ -346,6 +349,9 @@ const UI = {
|
||||
ondragleave="onProjectDragLeave(event)"
|
||||
ondrop="onRecentDrop(event)">
|
||||
<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>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user