1152 lines
42 KiB
Markdown
1152 lines
42 KiB
Markdown
# Design — v0.19.0 Projects / Workspaces
|
|
|
|
**Version:** 0.19.0
|
|
**Status:** Draft
|
|
**Depends on:** user groups + resource grants (v0.16.0), knowledge bases (v0.14.0), side panel (v0.18.1)
|
|
|
|
---
|
|
|
|
## 1. Overview
|
|
|
|
Projects are organizational containers that group related conversations,
|
|
knowledge bases, and notes into a shared workspace. They sit between
|
|
"individual channel" and "team" in the hierarchy — a team might have
|
|
many projects, a user might have personal projects across teams.
|
|
|
|
Projects solve two concrete problems:
|
|
|
|
1. **Sidebar sprawl:** Power users accumulate dozens of channels. The
|
|
flat (or single-folder) chat list doesn't scale. Projects give
|
|
channels meaningful grouping with shared context.
|
|
|
|
2. **Shared context:** Today, attaching a KB or Persona to a channel
|
|
is per-channel. Projects let you say "every conversation in this
|
|
project uses these KBs and this Persona" — one config, many channels.
|
|
|
|
**What this release does NOT include:** Notifications are deferred to
|
|
v0.19.1 (see §12 for the split rationale and forward pointers).
|
|
|
|
---
|
|
|
|
## 2. Scope Model
|
|
|
|
Projects reuse the existing three-value scope model:
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────┐
|
|
│ personal scope │
|
|
│ owner_id = user_id, team_id = NULL │
|
|
│ Only visible to the owning user │
|
|
│ Use case: personal workspace, side projects │
|
|
└─────────────────────────────────────────────────┘
|
|
|
|
┌─────────────────────────────────────────────────┐
|
|
│ team scope │
|
|
│ owner_id = creator, team_id = team_id │
|
|
│ Visible to team members (respects grants) │
|
|
│ Use case: department projects, client work │
|
|
└─────────────────────────────────────────────────┘
|
|
|
|
┌─────────────────────────────────────────────────┐
|
|
│ global scope │
|
|
│ owner_id = admin, team_id = NULL │
|
|
│ Visible to all users (admin-created) │
|
|
│ Use case: company-wide initiatives, templates │
|
|
└─────────────────────────────────────────────────┘
|
|
```
|
|
|
|
**Access control:** Projects use the same grant model as Personas and
|
|
KBs (v0.16.0): `team_only`, `global`, or `groups`. The `resource_grants`
|
|
table gets `'project'` added to its `resource_type` CHECK constraint.
|
|
|
|
---
|
|
|
|
## 3. Data Model
|
|
|
|
### 3.1 Table: `projects`
|
|
|
|
| Column | Type | Description |
|
|
|--------|------|-------------|
|
|
| `id` | UUID / TEXT | Primary key |
|
|
| `name` | TEXT NOT NULL | Display name |
|
|
| `description` | TEXT DEFAULT '' | Optional description |
|
|
| `scope` | TEXT NOT NULL | `personal`, `team`, `global` |
|
|
| `owner_id` | UUID / TEXT | Creating user |
|
|
| `team_id` | UUID / TEXT | NULL for personal/global |
|
|
| `persona_id` | UUID / TEXT | Default Persona for new channels (nullable) |
|
|
| `settings` | JSONB / JSON | Project-level config (see §3.7) |
|
|
| `is_archived` | BOOLEAN DEFAULT false | Soft archive |
|
|
| `created_at` | TIMESTAMPTZ / TEXT | |
|
|
| `updated_at` | TIMESTAMPTZ / TEXT | |
|
|
|
|
**Constraints:**
|
|
```sql
|
|
CONSTRAINT project_scope_check CHECK (
|
|
(scope = 'personal' AND team_id IS NULL) OR
|
|
(scope = 'team' AND team_id IS NOT NULL) OR
|
|
(scope = 'global' AND team_id IS NULL)
|
|
)
|
|
```
|
|
|
|
**Indexes:**
|
|
```sql
|
|
CREATE INDEX idx_projects_owner ON projects(owner_id);
|
|
CREATE INDEX idx_projects_team ON projects(team_id) WHERE team_id IS NOT NULL;
|
|
CREATE INDEX idx_projects_scope ON projects(scope);
|
|
```
|
|
|
|
### 3.2 Junction Tables
|
|
|
|
Separate junction tables per resource type — not a polymorphic
|
|
`project_resources` table. Rationale in §3.3.
|
|
|
|
**`project_channels`**
|
|
|
|
| Column | Type | Description |
|
|
|--------|------|-------------|
|
|
| `project_id` | UUID / TEXT | FK → projects ON DELETE CASCADE |
|
|
| `channel_id` | UUID / TEXT | UNIQUE, FK → channels ON DELETE CASCADE |
|
|
| `position` | INT DEFAULT 0 | Sort order within project |
|
|
| `folder_path` | TEXT DEFAULT '/' | Sub-folder within project |
|
|
| `added_at` | TIMESTAMPTZ / TEXT | |
|
|
|
|
```sql
|
|
PRIMARY KEY (project_id, channel_id)
|
|
UNIQUE (channel_id) -- one project per channel (see §16 Q2)
|
|
CREATE INDEX idx_project_channels_channel ON project_channels(channel_id);
|
|
```
|
|
|
|
**`project_knowledge_bases`**
|
|
|
|
| Column | Type | Description |
|
|
|--------|------|-------------|
|
|
| `project_id` | UUID / TEXT | FK → projects ON DELETE CASCADE |
|
|
| `kb_id` | UUID / TEXT | FK → knowledge_bases ON DELETE CASCADE |
|
|
| `auto_search` | BOOLEAN DEFAULT true | Include in all project channels |
|
|
| `added_at` | TIMESTAMPTZ / TEXT | |
|
|
|
|
```sql
|
|
PRIMARY KEY (project_id, kb_id)
|
|
```
|
|
|
|
**`project_notes`**
|
|
|
|
| Column | Type | Description |
|
|
|--------|------|-------------|
|
|
| `project_id` | UUID / TEXT | FK → projects ON DELETE CASCADE |
|
|
| `note_id` | UUID / TEXT | FK → notes ON DELETE CASCADE |
|
|
| `added_at` | TIMESTAMPTZ / TEXT | |
|
|
|
|
```sql
|
|
PRIMARY KEY (project_id, note_id)
|
|
```
|
|
|
|
### 3.3 Why Three Tables, Not One
|
|
|
|
The roadmap spec proposed a single `project_resources` table with
|
|
`(resource_type, resource_id)`. Three tables are better here:
|
|
|
|
- **Referential integrity.** FK constraints enforce that resources
|
|
exist. `ON DELETE CASCADE` handles cleanup when a channel, KB, or
|
|
note is deleted — no application-level orphan scanning.
|
|
|
|
- **Existing pattern.** `channel_knowledge_bases`, `persona_knowledge_bases`,
|
|
and `group_members` all use dedicated junction tables.
|
|
|
|
- **Type-specific columns.** `project_channels` needs `position` and
|
|
`folder_path`. `project_knowledge_bases` needs `auto_search`. A
|
|
polymorphic table would need nullable type-specific columns or a
|
|
JSONB bag, which is worse.
|
|
|
|
- **SQLite compatibility.** Simpler constraints, no CHECK on polymorphic
|
|
type strings, no mixed-resource indexes.
|
|
|
|
### 3.4 Channel Integration
|
|
|
|
Channels get a denormalized `project_id` for fast sidebar queries.
|
|
The junction table `project_channels` is the source of truth for
|
|
position and folder_path. Both are kept in sync by the store layer.
|
|
|
|
```sql
|
|
ALTER TABLE channels ADD COLUMN project_id UUID
|
|
REFERENCES projects(id) ON DELETE SET NULL;
|
|
CREATE INDEX idx_channels_project
|
|
ON channels(project_id) WHERE project_id IS NOT NULL;
|
|
```
|
|
|
|
**On project delete:** Channels detach (`project_id → NULL`). They are
|
|
never cascade-deleted — only the junction row is removed. KBs and notes
|
|
similarly survive; only the association is dropped.
|
|
|
|
### 3.5 Folder/Column Cleanup
|
|
|
|
The channels table currently has two unused grouping columns:
|
|
|
|
```sql
|
|
folder_id UUID -- FK placeholder, never wired to a folders table
|
|
folder TEXT -- frontend-managed text label, barely used
|
|
```
|
|
|
|
With projects providing real hierarchy:
|
|
|
|
- `folder_id` — **Dropped** in this migration (confirmed unused in
|
|
store layer and all handlers).
|
|
- `folder` — **Deprecated.** Column remains for backward compat but is
|
|
no longer read or written by any handler after v0.19.0. Migration
|
|
best-effort copies non-empty `folder` values into personal projects
|
|
(one project per distinct folder name, channels attached). Can be
|
|
dropped in a future schema consolidation.
|
|
|
|
### 3.6 Model Structs
|
|
|
|
```go
|
|
type Project struct {
|
|
BaseModel
|
|
Name string `json:"name" db:"name"`
|
|
Description string `json:"description" db:"description"`
|
|
Scope string `json:"scope" db:"scope"`
|
|
OwnerID string `json:"owner_id" db:"owner_id"`
|
|
TeamID *string `json:"team_id,omitempty" db:"team_id"`
|
|
PersonaID *string `json:"persona_id,omitempty" db:"persona_id"`
|
|
Settings JSONMap `json:"settings,omitempty" db:"settings"`
|
|
IsArchived bool `json:"is_archived" db:"is_archived"`
|
|
|
|
// Computed (from COUNT joins, not stored)
|
|
ChannelCount int `json:"channel_count,omitempty"`
|
|
KBCount int `json:"kb_count,omitempty"`
|
|
NoteCount int `json:"note_count,omitempty"`
|
|
}
|
|
|
|
type ProjectPatch struct {
|
|
Name *string `json:"name,omitempty"`
|
|
Description *string `json:"description,omitempty"`
|
|
PersonaID *string `json:"persona_id,omitempty"`
|
|
Settings JSONMap `json:"settings,omitempty"`
|
|
IsArchived *bool `json:"is_archived,omitempty"`
|
|
}
|
|
|
|
type ProjectChannel struct {
|
|
ProjectID string `json:"project_id" db:"project_id"`
|
|
ChannelID string `json:"channel_id" db:"channel_id"`
|
|
Position int `json:"position" db:"position"`
|
|
FolderPath string `json:"folder_path" db:"folder_path"`
|
|
}
|
|
```
|
|
|
|
### 3.7 Project Settings (JSONB)
|
|
|
|
The `settings` column stores project-level configuration as JSON.
|
|
Initial keys:
|
|
|
|
```json
|
|
{
|
|
"auto_persona": true,
|
|
"auto_kbs": true,
|
|
"default_model": null,
|
|
"description_visible": true
|
|
}
|
|
```
|
|
|
|
| Key | Type | Description |
|
|
|-----|------|-------------|
|
|
| `auto_persona` | bool | Apply project Persona to new channels automatically |
|
|
| `auto_kbs` | bool | Inject project KBs into channels at completion time |
|
|
| `default_model` | string | Override model for new channels (nullable) |
|
|
| `description_visible` | bool | Show description in sidebar project header |
|
|
|
|
Settings are intentionally sparse. Resist the urge to add per-project
|
|
copies of global settings — use project Persona config for model
|
|
behavior and project KBs for context.
|
|
|
|
---
|
|
|
|
## 4. Store Interface
|
|
|
|
```go
|
|
type ProjectStore interface {
|
|
// CRUD
|
|
Create(ctx context.Context, p *models.Project) error
|
|
GetByID(ctx context.Context, id string) (*models.Project, error)
|
|
Update(ctx context.Context, id string, patch models.ProjectPatch) error
|
|
Delete(ctx context.Context, id string) error
|
|
|
|
// Listing (scope-aware)
|
|
ListForUser(ctx context.Context, userID string, opts store.ListOptions) ([]models.Project, int, error)
|
|
ListForTeam(ctx context.Context, teamID string, opts store.ListOptions) ([]models.Project, int, error)
|
|
ListAccessible(ctx context.Context, userID string) ([]models.Project, error)
|
|
|
|
// Resource association
|
|
AddChannel(ctx context.Context, projectID, channelID string) error
|
|
RemoveChannel(ctx context.Context, projectID, channelID string) error
|
|
ListChannels(ctx context.Context, projectID string) ([]models.ProjectChannel, error)
|
|
MoveChannel(ctx context.Context, projectID, channelID string, position int, folderPath string) error
|
|
|
|
AddKB(ctx context.Context, projectID, kbID string, autoSearch bool) error
|
|
RemoveKB(ctx context.Context, projectID, kbID string) error
|
|
ListKBs(ctx context.Context, projectID string) ([]models.KnowledgeBase, error)
|
|
|
|
AddNote(ctx context.Context, projectID, noteID string) error
|
|
RemoveNote(ctx context.Context, projectID, noteID string) error
|
|
ListNotes(ctx context.Context, projectID string) ([]models.Note, error)
|
|
|
|
// KB resolution (for completion-time injection)
|
|
GetProjectKBsForChannel(ctx context.Context, channelID string) ([]models.KnowledgeBase, error)
|
|
|
|
// Access check
|
|
UserCanAccess(ctx context.Context, userID, projectID string) (bool, error)
|
|
}
|
|
```
|
|
|
|
**Key design decisions:**
|
|
|
|
- `AddChannel` writes both `project_channels` junction row and
|
|
`channels.project_id` denorm column in a single transaction. If
|
|
the channel already belongs to a different project, AddChannel
|
|
removes the old association first (atomic move). This enables
|
|
drag-between-projects and "Move to project" context menu.
|
|
- `RemoveChannel` clears `channels.project_id` and deletes the
|
|
junction row.
|
|
- `GetProjectKBsForChannel` joins `channels → project_channels →
|
|
project_knowledge_bases → knowledge_bases` — used at completion time
|
|
for virtual KB injection (see §6).
|
|
- `ListAccessible` uses the same grant resolution pattern as
|
|
`PersonaStore.ListAccessible`: union of personal + team + granted.
|
|
- Both Postgres and SQLite implementations required (same pattern as
|
|
all other stores).
|
|
|
|
---
|
|
|
|
## 5. API Endpoints
|
|
|
|
All endpoints require authentication. Team/global project mutations
|
|
require team_admin or admin role respectively.
|
|
|
|
### 5.1 Projects CRUD
|
|
|
|
```
|
|
POST /api/v1/projects Create project
|
|
GET /api/v1/projects List accessible projects
|
|
GET /api/v1/projects/:id Get project (with resource counts)
|
|
PUT /api/v1/projects/:id Update project
|
|
DELETE /api/v1/projects/:id Delete project (detaches resources)
|
|
```
|
|
|
|
**Create request:**
|
|
```json
|
|
{
|
|
"name": "Q3 Launch",
|
|
"description": "Product launch planning",
|
|
"scope": "team",
|
|
"team_id": "uuid",
|
|
"persona_id": "uuid-or-null",
|
|
"settings": {}
|
|
}
|
|
```
|
|
|
|
**List response:**
|
|
```json
|
|
{
|
|
"data": [
|
|
{
|
|
"id": "uuid",
|
|
"name": "Q3 Launch",
|
|
"scope": "team",
|
|
"channel_count": 5,
|
|
"kb_count": 2,
|
|
"note_count": 12,
|
|
"persona_id": "uuid",
|
|
"updated_at": "2026-02-28T..."
|
|
}
|
|
],
|
|
"total": 3,
|
|
"page": 1,
|
|
"per_page": 50
|
|
}
|
|
```
|
|
|
|
### 5.2 Resource Association
|
|
|
|
```
|
|
POST /api/v1/projects/:id/channels Add channel to project
|
|
DELETE /api/v1/projects/:id/channels/:cid Remove channel from project
|
|
GET /api/v1/projects/:id/channels List project channels
|
|
PUT /api/v1/projects/:id/channels/:cid Move/reorder channel
|
|
|
|
POST /api/v1/projects/:id/knowledge-bases Add KB
|
|
DELETE /api/v1/projects/:id/knowledge-bases/:kid Remove KB
|
|
GET /api/v1/projects/:id/knowledge-bases List project KBs
|
|
|
|
POST /api/v1/projects/:id/notes Add note
|
|
DELETE /api/v1/projects/:id/notes/:nid Remove note
|
|
GET /api/v1/projects/:id/notes List project notes
|
|
```
|
|
|
|
**Add channel request:**
|
|
```json
|
|
{
|
|
"channel_id": "uuid",
|
|
"folder_path": "/design",
|
|
"position": 0
|
|
}
|
|
```
|
|
|
|
**Move channel request:**
|
|
```json
|
|
{
|
|
"position": 2,
|
|
"folder_path": "/design/iterations"
|
|
}
|
|
```
|
|
|
|
### 5.3 Project Grants
|
|
|
|
Reuses the existing resource grants API pattern:
|
|
|
|
```
|
|
GET /api/v1/projects/:id/grants Get project grant
|
|
PUT /api/v1/projects/:id/grants Set project grant
|
|
```
|
|
|
|
**Request/response uses existing grant model:**
|
|
```json
|
|
{
|
|
"grant_scope": "groups",
|
|
"granted_groups": ["uuid-group-1", "uuid-group-2"]
|
|
}
|
|
```
|
|
|
|
**Migration:** Add `'project'` to `resource_grants.resource_type` CHECK:
|
|
```sql
|
|
ALTER TABLE resource_grants DROP CONSTRAINT resource_grants_resource_type_check;
|
|
ALTER TABLE resource_grants ADD CONSTRAINT resource_grants_resource_type_check
|
|
CHECK (resource_type IN ('persona', 'knowledge_base', 'project'));
|
|
```
|
|
|
|
### 5.4 Channel List Enhancement
|
|
|
|
The existing `GET /api/v1/channels` endpoint gains project filtering:
|
|
|
|
```
|
|
GET /api/v1/channels?project_id=uuid Channels in a project
|
|
GET /api/v1/channels?project_id=none Unassigned channels only
|
|
```
|
|
|
|
**Technical debt addressed here:** The `ListChannels` handler currently
|
|
does raw SQL against `database.DB` instead of using `ChannelStore`.
|
|
This gets migrated to the store layer as part of this work. The handler
|
|
is the only one still bypassing the store — fixing it improves both
|
|
maintainability and SQLite compat testing.
|
|
|
|
---
|
|
|
|
## 6. KB Injection at Completion Time
|
|
|
|
Project KBs use **virtual injection** at completion time — the same
|
|
pattern as Persona-KB binding (v0.17.0). No physical rows are created
|
|
in `channel_knowledge_bases`.
|
|
|
|
**Priority chain for KB resolution:**
|
|
|
|
```
|
|
channel KBs (explicit per-channel) → highest priority
|
|
↓
|
|
project KBs (auto_search = true) → inherited from project
|
|
↓
|
|
persona KBs (persona_knowledge_bases) → inherited from Persona
|
|
```
|
|
|
|
At completion time, `loadConversation()` already calls
|
|
`buildKBContext()` which resolves channel + persona KBs. This extends
|
|
to include project KBs:
|
|
|
|
```go
|
|
// In completion handler, after resolving channel
|
|
func resolveKBs(ctx context.Context, stores store.Stores,
|
|
channelID string, personaID *string) []string {
|
|
|
|
var kbIDs []string
|
|
|
|
// 1. Channel-level (explicit)
|
|
channelKBs, _ := stores.KnowledgeBases.ListForChannel(ctx, channelID)
|
|
kbIDs = append(kbIDs, extractIDs(channelKBs)...)
|
|
|
|
// 2. Project-level (virtual injection)
|
|
projectKBs, _ := stores.Projects.GetProjectKBsForChannel(ctx, channelID)
|
|
kbIDs = append(kbIDs, extractIDs(projectKBs)...)
|
|
|
|
// 3. Persona-level
|
|
if personaID != nil {
|
|
personaKBs, _ := stores.KnowledgeBases.ListForPersona(ctx, *personaID)
|
|
kbIDs = append(kbIDs, extractIDs(personaKBs)...)
|
|
}
|
|
|
|
return deduplicate(kbIDs)
|
|
}
|
|
```
|
|
|
|
**Why virtual, not physical:** Adding a KB to a project should
|
|
immediately apply to all project channels without backfilling junction
|
|
rows. Removing a KB should immediately stop applying. The Persona-KB
|
|
pattern proved this works — query-time resolution is simple and the
|
|
JOIN cost is negligible for the cardinality involved.
|
|
|
|
---
|
|
|
|
## 7. Sidebar UI
|
|
|
|
### 7.1 Layout
|
|
|
|
The sidebar transforms from a flat chat list to a grouped view:
|
|
|
|
```
|
|
┌──────────────────────────────┐
|
|
│ 🔀 Chat Switchboard │
|
|
│ │
|
|
│ 🔍 Search... │
|
|
│ │
|
|
│ [+ New Chat] │
|
|
│ │
|
|
│ 📌 Pinned │
|
|
│ Chat about deployment │
|
|
│ Weekly standup notes │
|
|
│ │
|
|
│ ▼ Q3 Launch (team) ⚙ │
|
|
│ ├── /design │
|
|
│ │ Chat: mockup review │
|
|
│ │ Chat: color palette │
|
|
│ ├── /copy │
|
|
│ │ Chat: landing page │
|
|
│ └── Chat: kickoff │
|
|
│ │
|
|
│ ▼ Side Projects (personal) ⚙ │
|
|
│ Chat: rust experiments │
|
|
│ Chat: garden planner │
|
|
│ │
|
|
│ ▶ Archived Project ⚙ │
|
|
│ │
|
|
│ ── Recent ── │
|
|
│ Chat: quick question │
|
|
│ Chat: random thought │
|
|
│ │
|
|
│ [+ New Project] │
|
|
└──────────────────────────────┘
|
|
```
|
|
|
|
**Sections (top to bottom):**
|
|
|
|
1. **Pinned** — pinned channels, regardless of project assignment
|
|
(existing behavior, unchanged)
|
|
2. **Projects** — collapsible groups, sorted by `updated_at` desc.
|
|
Each project shows its channels organized by `folder_path`.
|
|
3. **Recent** — unassigned channels (`project_id IS NULL`,
|
|
`is_pinned = false`), sorted by `updated_at` desc. This is the
|
|
existing chat list behavior for channels not yet organized.
|
|
|
|
### 7.2 Interactions
|
|
|
|
| Action | Behavior |
|
|
|--------|----------|
|
|
| Click project header | Toggle expand/collapse (state persisted) |
|
|
| Click ⚙ gear icon | Open project settings in side panel |
|
|
| Drag channel → project | `POST /projects/:id/channels` |
|
|
| Drag channel → "Recent" | `DELETE /projects/:id/channels/:cid` |
|
|
| Drag channel within project | `PUT /projects/:id/channels/:cid` (reorder) |
|
|
| Right-click project | Context menu: rename, archive, delete, manage KBs |
|
|
| `[+ New Project]` | Inline creation: name input → scope picker |
|
|
| Double-click project name | Inline rename (same UX as chat rename from v0.17.0) |
|
|
|
|
### 7.3 State Persistence
|
|
|
|
```js
|
|
// sessionStorage key: 'cs-project-collapse'
|
|
// Value: JSON object { [projectId]: boolean }
|
|
// true = collapsed, absent/false = expanded
|
|
```
|
|
|
|
### 7.4 Data Loading
|
|
|
|
The sidebar needs all projects + their channels in one round trip to
|
|
avoid N+1 rendering. Two options:
|
|
|
|
**Option A — Two calls (preferred):**
|
|
```
|
|
GET /api/v1/projects → project list with counts
|
|
GET /api/v1/channels?per_page=200 → all channels (project_id included)
|
|
```
|
|
Client-side grouping by `project_id`. This reuses existing channel
|
|
loading and adds one extra call. Channel data is already loaded at
|
|
startup in `loadChats()`.
|
|
|
|
**Option B — Single enriched call:**
|
|
```
|
|
GET /api/v1/projects?include=channels
|
|
```
|
|
Returns projects with embedded channel arrays. More efficient but
|
|
requires a new response shape and duplicates channel data if the
|
|
flat list is also needed.
|
|
|
|
Option A wins on KISS. The project list is small (rarely > 20), the
|
|
channel list is already loaded, and grouping is trivial in JS.
|
|
|
|
### 7.5 Mobile
|
|
|
|
On viewports ≤ 768px:
|
|
- Projects render as collapsible accordions (same as desktop)
|
|
- Drag-and-drop disabled — use context menu → "Move to project" instead
|
|
- Project gear icon opens a bottom sheet instead of side panel
|
|
- Folder paths shown as flat prefixes (no tree indentation)
|
|
|
|
---
|
|
|
|
## 8. Project Settings Panel
|
|
|
|
Project settings open in the side panel (v0.18.1 PanelRegistry).
|
|
Registers as `'project-settings'` panel.
|
|
|
|
**Sections:**
|
|
|
|
1. **General** — name, description, scope badge (read-only for
|
|
non-admin), archive toggle
|
|
2. **Default Persona** — persona picker dropdown (same component as
|
|
channel persona picker from `persona-kb.js`)
|
|
3. **Knowledge Bases** — checkbox list of accessible KBs with
|
|
`auto_search` toggle per KB
|
|
4. **Access** — grant picker (team_only / global / groups), reuses
|
|
the same grant picker UI from Persona settings
|
|
5. **Resources** — summary counts (N channels, N KBs, N notes)
|
|
6. **Danger Zone** — delete project (confirmation dialog: "Resources
|
|
will be detached, not deleted")
|
|
|
|
---
|
|
|
|
## 9. Channel Creation Within Projects
|
|
|
|
When creating a new channel from within a project context (clicking
|
|
"New Chat" while a project is expanded, or via project context menu):
|
|
|
|
1. Channel created via existing `POST /api/v1/channels`
|
|
2. Immediately associated via `POST /projects/:id/channels`
|
|
3. If project has `auto_persona: true` and `persona_id` set, the
|
|
channel inherits the project's default Persona
|
|
4. Project KBs automatically apply at completion time via virtual
|
|
injection (§6) — no explicit channel-KB linking needed
|
|
|
|
The frontend tracks an "active project context" so new channels
|
|
created while browsing a project are auto-associated. Context clears
|
|
when the user clicks outside any project or into "Recent."
|
|
|
|
```js
|
|
// In app state
|
|
App.activeProjectId = null; // set when user interacts with a project
|
|
```
|
|
|
|
---
|
|
|
|
## 10. Enterprise Use Cases
|
|
|
|
### 10.1 Project Templates (Phase 3 stretch)
|
|
|
|
Team admins create projects pre-configured with Personas, KBs, and
|
|
settings. Templates are projects with `settings.is_template: true`.
|
|
Creating "from template" copies configuration into a new project.
|
|
|
|
Not in initial delivery — data model supports it without changes.
|
|
|
|
### 10.2 Cross-Team Projects
|
|
|
|
Global-scope projects with group grants enable cross-team work.
|
|
Members of granted groups see the project and its channels. Uses
|
|
existing grant resolution from v0.16.0 — no new access control code.
|
|
|
|
### 10.3 Admin Oversight
|
|
|
|
Global admins can list all projects via admin panel. New "Projects"
|
|
section under the "People" category (alongside Teams and Groups).
|
|
Table view with scope, owner, team, resource counts, last updated.
|
|
|
|
---
|
|
|
|
## 11. Implementation Phases
|
|
|
|
### Phase 1 — Data Model + Store + API
|
|
|
|
Backend foundation. All store operations, migrations, and API endpoints.
|
|
|
|
**New files:**
|
|
- `server/database/migrations/006_v0190_projects.sql` (Postgres)
|
|
- `server/database/migrations/sqlite/006_v0190_projects.sql` (SQLite)
|
|
- `server/store/postgres/project.go` (ProjectStore implementation)
|
|
- `server/store/sqlite/project.go` (ProjectStore implementation)
|
|
- `server/handlers/projects.go` (CRUD + resource association + grants)
|
|
|
|
**Modified files:**
|
|
- `server/models/models.go` — add Project, ProjectPatch, ProjectChannel structs
|
|
- `server/store/interfaces.go` — add ProjectStore interface, Projects field to Stores
|
|
- `server/store/postgres/stores.go` — wire ProjectStore in NewStores()
|
|
- `server/store/sqlite/stores.go` — wire ProjectStore in NewStores()
|
|
- `server/main.go` — wire project routes, inject store
|
|
- `server/handlers/completion.go` — extend KB resolution chain (§6)
|
|
- `server/handlers/channels.go` — migrate ListChannels to store layer,
|
|
add `project_id` filter parameter
|
|
|
|
**Technical debt resolved:**
|
|
- `ListChannels` handler: raw SQL → ChannelStore (only remaining
|
|
handler bypassing the store layer)
|
|
- `folder_id` column dropped (unused FK placeholder)
|
|
|
|
### Phase 2 — Sidebar UI
|
|
|
|
Frontend project grouping, collapse/expand, drag-and-drop.
|
|
|
|
**New files:**
|
|
- `src/js/projects.js` — ProjectManager: CRUD calls, sidebar rendering,
|
|
drag handlers, active project context, collapse state
|
|
|
|
**Modified files:**
|
|
- `src/js/ui-core.js` — `renderChatList()` → delegates to ProjectManager
|
|
for grouped rendering, falls back to flat list when no projects exist
|
|
- `src/js/chat.js` — `loadChats()` adds project fetch, channel objects
|
|
include `project_id`
|
|
- `src/js/api.js` — project API methods (CRUD, resource association)
|
|
- `src/js/app.js` — `App.activeProjectId`, keyboard shortcuts, project
|
|
context for new channel creation
|
|
- `src/css/styles.css` — project group styles, folder indent, drag
|
|
target highlights, collapse animations
|
|
- `src/index.html` — sidebar structure updates, "New Project" button
|
|
|
|
### Phase 3 — Settings Panel + Admin
|
|
|
|
Side panel project settings, admin panel integration.
|
|
|
|
**New files:**
|
|
- `src/js/project-settings.js` — PanelRegistry registration,
|
|
settings form, KB picker, grant picker, delete flow
|
|
|
|
**Modified files:**
|
|
- `src/js/panels.js` — project-settings panel registration
|
|
- `src/js/ui-admin.js` — admin "Projects" section (table + CRUD)
|
|
- `src/js/persona-kb.js` — extract KB picker as reusable component
|
|
for both Persona and Project settings
|
|
- `src/css/styles.css` — settings panel styles
|
|
|
|
---
|
|
|
|
## 12. Deferred: Notifications (→ v0.19.1, v0.19.2)
|
|
|
|
Notifications are split out because:
|
|
|
|
1. **Independent value.** Projects are useful without notifications.
|
|
Notifications are useful without projects. Neither blocks the other.
|
|
|
|
2. **Scope control.** Notifications span persistence, real-time
|
|
WebSocket delivery, email transport with SMTP, HTML templates,
|
|
digest batching, and per-user preferences. That's a standalone
|
|
feature set with its own store, handler, and UI.
|
|
|
|
3. **Email is ops-heavy.** SMTP configuration, template rendering, and
|
|
digest scheduling affect deployment requirements. Air-gapped envs
|
|
won't have SMTP. Separating lets us ship in-app notifications first.
|
|
|
|
**v0.19.1 — In-App Notifications:**
|
|
- `notifications` table + NotificationStore (both dialects)
|
|
- In-app notification bell (header bar, unread count badge)
|
|
- Notification dropdown: grouped by type, mark read, click-to-navigate
|
|
- WebSocket push via EventBus (`notification.new` → `DirToClient` in routeTable)
|
|
- Sources: grant changes, KB processing complete, project invites,
|
|
memory review queue (completes v0.18.0 admin workflow), team invites
|
|
|
|
**v0.19.2 — Email Transport (tentative):**
|
|
- SMTP config in admin settings (host, port, from, TLS)
|
|
- HTML + plaintext templates, branded with instance name
|
|
- Per-user preferences: per-type toggles (in-app / email / off)
|
|
- Digest mode: batch low-priority notifications (hourly/daily)
|
|
- Admin enable/disable email globally or per-team
|
|
|
|
---
|
|
|
|
## 13. Forward Compatibility
|
|
|
|
### 13.1 Multi-Participant Channels (v0.23.0)
|
|
|
|
Projects don't assume single-owner channels. A project channel that
|
|
later becomes a group channel (with `channel_participants`) works
|
|
unchanged — `project_channels` joins on `channel_id`, not `user_id`.
|
|
|
|
### 13.2 Workflow Engine (v0.25.0)
|
|
|
|
Workflow channels (`type='workflow'`) can belong to projects. A project
|
|
could group all workflow instances for a process. The `project_id` on
|
|
channels is type-agnostic.
|
|
|
|
### 13.3 Project-Scoped Memory (future)
|
|
|
|
Not implemented, but the data model supports it. If a project has a
|
|
default Persona, memories from project channels naturally feed into
|
|
persona-scoped memory via existing v0.18.0 mechanics. No schema changes.
|
|
|
|
### 13.4 Extension Surfaces (v0.21.0)
|
|
|
|
The project settings panel uses PanelRegistry (v0.18.1). When extension
|
|
surfaces land, a project could define which surfaces/modes are available
|
|
to its channels via `settings` JSON extension — no schema change.
|
|
|
|
---
|
|
|
|
## 14. Migration Safety
|
|
|
|
### 14.1 Postgres
|
|
|
|
Migration `006_v0190_projects.sql` is additive:
|
|
- New tables: `projects`, `project_channels`, `project_knowledge_bases`,
|
|
`project_notes`
|
|
- New column: `channels.project_id` (nullable FK, ON DELETE SET NULL)
|
|
- Altered CHECK: `resource_grants.resource_type` adds `'project'`
|
|
- Dropped column: `channels.folder_id` (confirmed unused)
|
|
|
|
All operations are backward-compatible. Existing channels work without
|
|
project assignment. The migration is safe to apply to live databases
|
|
(no table locks beyond the brief ALTER TABLE).
|
|
|
|
### 14.2 SQLite
|
|
|
|
`sqlite/006_v0190_projects.sql` with standard dialect adaptations:
|
|
- `UUID` → `TEXT`
|
|
- `TIMESTAMPTZ` → `TEXT`
|
|
- `JSONB` → `TEXT` (JSON)
|
|
- `gen_random_uuid()` → app-side `store.NewID()`
|
|
- `ALTER TABLE channels ADD COLUMN project_id TEXT` (no FK enforcement
|
|
in SQLite, but the store layer handles referential integrity)
|
|
|
|
Note: `ALTER TABLE DROP COLUMN` requires SQLite ≥ 3.35.0. Ubuntu 24
|
|
ships 3.45, so `folder_id` drop is safe. Verify minimum SQLite version
|
|
in CI.
|
|
|
|
### 14.3 Rollback
|
|
|
|
All new tables can be dropped without affecting existing data. The
|
|
`channels.project_id` column can be dropped (nullable, no data loss).
|
|
The `resource_grants` CHECK can be reverted. No destructive operations
|
|
on existing tables.
|
|
|
|
### 14.4 Folder Migration (best-effort)
|
|
|
|
For users with existing `channels.folder` values, the migration
|
|
creates personal projects from distinct folder names:
|
|
|
|
```sql
|
|
-- Pseudo-SQL (actual implementation in Go for SQLite compat)
|
|
-- For each distinct (user_id, folder) pair where folder is non-empty:
|
|
-- 1. Create a personal project with name = folder
|
|
-- 2. Insert project_channels row
|
|
-- 3. Set channels.project_id
|
|
```
|
|
|
|
Implementation is in the Go migration runner (not raw SQL) because
|
|
SQLite needs `store.NewID()` for UUID generation. If no `folder`
|
|
values exist, nothing happens. This is a convenience migration, not
|
|
a correctness requirement — users can reorganize manually.
|
|
|
|
---
|
|
|
|
## 15. Test Plan
|
|
|
|
### 15.1 Store Integration Tests (both dialects)
|
|
|
|
- Project CRUD: create, read, update, delete, list
|
|
- Scope enforcement: personal projects invisible to other users,
|
|
team projects visible to team members, global visible to all
|
|
- Resource association: add/remove channels, KBs, notes
|
|
- Cascade: project delete detaches channels (project_id → NULL)
|
|
- Cascade: channel delete removes junction row, project unaffected
|
|
- Cascade: KB delete removes junction row, project unaffected
|
|
- `GetProjectKBsForChannel`: correct KBs through the full join chain
|
|
(channel → project_channel → project_kb → kb)
|
|
- `GetProjectKBsForChannel`: returns empty for unassigned channels
|
|
- Grant resolution: `UserCanAccess` for team_only, global, group grants
|
|
- `ListAccessible`: union of personal + team + granted
|
|
- One-project-per-channel: UNIQUE constraint on `project_channels.channel_id`
|
|
rejects duplicate assignment
|
|
- `MoveChannel`: position and folder_path update correctly
|
|
|
|
### 15.2 Handler Integration Tests
|
|
|
|
- CRUD with auth: admin creates global, team_admin creates team,
|
|
user creates personal, unauthorized users rejected
|
|
- Grant management: set/get grants, verify access changes
|
|
- Channel filter: `?project_id=uuid` returns only project channels,
|
|
`?project_id=none` returns only unassigned
|
|
- KB injection: completion with project KBs includes them in search
|
|
context alongside channel and persona KBs
|
|
|
|
### 15.3 Frontend Tests (manual + smoke)
|
|
|
|
- Sidebar renders project groups with correct channels
|
|
- Collapse/expand persists across page refresh
|
|
- Drag channel into project updates sidebar and API
|
|
- Drag channel between projects moves cleanly
|
|
- Drag channel to "Recent" removes from project
|
|
- New channel within project context auto-associates
|
|
- Project settings panel opens with correct data
|
|
- Project delete detaches channels (they appear in "Recent")
|
|
|
|
### 15.4 Unhappy Path / Defensive Tests
|
|
|
|
- Corrupt JSON in `channels.settings` → `scanJSON` defaults to `{}`,
|
|
warning logged, channel list still returns valid response
|
|
- Corrupt JSON in `channels.tags` → `scanTags` defaults to `[]`,
|
|
warning logged
|
|
- `SafeJSON` catches marshal failure → returns 500, not truncated 200
|
|
- Startup integrity check detects corrupt rows and logs warnings
|
|
- Channel moved between projects: old junction row removed, new one
|
|
created, `project_id` denorm updated — all atomic
|
|
- Project delete with channels: channels detach cleanly, appear in
|
|
"Recent", no orphaned junction rows
|
|
- KB removed from project: immediately stops appearing in completion
|
|
context for project channels (virtual injection, no stale rows)
|
|
- Non-existent project_id filter on channels → empty result, not error
|
|
- Adding channel to project when channel already in another project →
|
|
atomic move, not duplicate
|
|
|
|
---
|
|
|
|
## 16. Resolved Questions
|
|
|
|
1. **Should "New Chat" always prompt for project?** **No.** New chats
|
|
go to "Recent" by default. Users drag them into projects when worth
|
|
keeping. Channels created from within a project context (expanded
|
|
project group) auto-associate. Low-friction quick-chat UX preserved.
|
|
|
|
2. **Channel in multiple projects?** **One project per channel.**
|
|
Enforced by UNIQUE on `project_channels.channel_id`. Moving a
|
|
channel between projects must be easy — the UI should support
|
|
drag-to-different-project and a "Move to project" context menu
|
|
action. The `AddChannel` store method handles the move atomically:
|
|
remove from old project (if any), add to new, update denorm column.
|
|
|
|
3. **Project-level search?** **Deferred to v0.19.1 or v0.19.2.** The
|
|
junction tables make this straightforward — `WHERE channel_id IN
|
|
(SELECT channel_id FROM project_channels WHERE project_id = $1)`.
|
|
Not MVP but real utility for power users with many project channels.
|
|
|
|
4. **Note auto-association?** **Yes.** When a note is created from a
|
|
message in a project channel, auto-associate it with the project
|
|
via `project_notes`. The provenance chain (channel → project → note)
|
|
is natural and expected. Implemented in the note creation handler
|
|
by checking `channel.project_id` and inserting the junction row.
|
|
|
|
---
|
|
|
|
## 17. Bugfixes Bundled with v0.19.0
|
|
|
|
### 17.0 Defensive Coding: Unhappy Path Philosophy
|
|
|
|
Chat Switchboard needs to be resilient to dirty data, especially
|
|
post-upgrade. Users (and developers) don't always back up before
|
|
upgrading. Migrations can leave stale or corrupt data. External inputs
|
|
(API keys, provider responses, pasted content) can inject unexpected
|
|
bytes. The codebase should **warn and continue** rather than crash or
|
|
produce truncated responses.
|
|
|
|
**Principles:**
|
|
|
|
- **Validate on read, not just write.** Even if the write path ensures
|
|
valid JSON, the read path must handle the case where it isn't.
|
|
Migrations, manual DB edits, encoding bugs, and version skew can all
|
|
produce invalid data.
|
|
|
|
- **Degrade gracefully.** A corrupt `settings` column on one channel
|
|
should not prevent loading all channels. Default to `{}` or `[]`,
|
|
log a warning with the row ID, and continue.
|
|
|
|
- **Never send 200 with a broken body.** Pre-validate serialization
|
|
for endpoints that touch user-controlled JSON columns. If marshaling
|
|
fails, return 500 with a useful error, not a truncated stream.
|
|
|
|
- **Log actionable context.** Warnings should include: table, column,
|
|
row ID, and the nature of the corruption. This lets admins find and
|
|
fix the specific row without guessing.
|
|
|
|
- **Apply everywhere JSON columns are scanned.** The `scanJSON` and
|
|
`scanTags` helpers are used across channels, settings, personas,
|
|
and knowledge bases. The fix must be in the shared helpers, not
|
|
patched per-handler.
|
|
|
|
### 17.1 Channels endpoint returns truncated JSON (P0)
|
|
|
|
**Root cause found:** A channel row has a corrupted `settings` column
|
|
containing a `\x02` (STX control byte) instead of valid JSON. The
|
|
`scanJSON` helper copies raw bytes without validation — the scan
|
|
succeeds. Then `c.JSON(200, ...)` starts writing the response (status
|
|
+ headers flushed), the JSON encoder hits the corrupt `RawMessage`,
|
|
`MarshalJSON()` validates the content, finds `\x02`, aborts
|
|
mid-stream. Truncated body. Browser gets "Unexpected end of JSON input".
|
|
|
|
Server log confirmation (fires on every channels request):
|
|
```
|
|
Error #01: json: error calling MarshalJSON for type json.RawMessage:
|
|
invalid character '\x02' looking for beginning of value
|
|
```
|
|
|
|
**Fix (three layers):**
|
|
|
|
**Layer 1 — Harden `scanJSON` (shared helper):**
|
|
```go
|
|
func (s *jsonScanner) Scan(src interface{}) error {
|
|
switch v := src.(type) {
|
|
case []byte:
|
|
if !json.Valid(v) {
|
|
log.Printf("⚠ scanJSON: invalid JSON in column (len=%d, preview=%.40q), defaulting to {}", len(v), v)
|
|
*s.dest = json.RawMessage("{}")
|
|
return nil
|
|
}
|
|
*s.dest = json.RawMessage(v)
|
|
case string:
|
|
b := []byte(v)
|
|
if !json.Valid(b) {
|
|
log.Printf("⚠ scanJSON: invalid JSON in column (len=%d, preview=%.40q), defaulting to {}", len(b), b)
|
|
*s.dest = json.RawMessage("{}")
|
|
return nil
|
|
}
|
|
*s.dest = json.RawMessage(b)
|
|
case nil:
|
|
*s.dest = json.RawMessage("{}")
|
|
default:
|
|
*s.dest = json.RawMessage("{}")
|
|
}
|
|
return nil
|
|
}
|
|
```
|
|
|
|
This ensures no corrupt data reaches the JSON encoder. The warning
|
|
log includes a truncated preview to help identify the source without
|
|
dumping the full corrupt blob.
|
|
|
|
**Layer 2 — Same treatment for `scanTags`:**
|
|
Apply identical validation to the tags scanner. If the stored value
|
|
isn't valid JSON array, default to `[]` and warn.
|
|
|
|
**Layer 3 — Pre-marshal on list endpoints:**
|
|
For endpoints that return arrays of objects with `json.RawMessage`
|
|
fields, pre-marshal the response to catch errors before headers are
|
|
sent:
|
|
|
|
```go
|
|
// Helper: SafeJSON writes status + body only after successful marshal
|
|
func SafeJSON(c *gin.Context, code int, obj interface{}) {
|
|
body, err := json.Marshal(obj)
|
|
if err != nil {
|
|
log.Printf("⚠ SafeJSON: marshal failed: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|
"error": "response serialization failed",
|
|
})
|
|
return
|
|
}
|
|
c.Data(code, "application/json; charset=utf-8", body)
|
|
}
|
|
```
|
|
|
|
Apply to: `ListChannels`, `GetChannel`, `ListPersonas`, and any other
|
|
handler returning models with JSONB fields.
|
|
|
|
**Layer 4 — Startup integrity check (warn-only):**
|
|
On application boot, after migrations complete, run a lightweight
|
|
scan for corrupt JSON columns:
|
|
|
|
```go
|
|
func checkJSONIntegrity(db *sql.DB) {
|
|
tables := []struct{ table, column string }{
|
|
{"channels", "settings"},
|
|
{"channels", "tags"},
|
|
{"personas", "settings"},
|
|
{"knowledge_bases", "metadata"},
|
|
{"users", "settings"},
|
|
}
|
|
for _, t := range tables {
|
|
// Postgres: check for non-JSON values
|
|
query := fmt.Sprintf(
|
|
`SELECT id FROM %s WHERE %s IS NOT NULL
|
|
AND %s::text != '' AND %s::text NOT SIMILAR TO '[{"[\s]%%'`,
|
|
t.table, t.column, t.column, t.column)
|
|
rows, err := db.Query(query)
|
|
if err != nil { continue }
|
|
var ids []string
|
|
for rows.Next() {
|
|
var id string
|
|
rows.Scan(&id)
|
|
ids = append(ids, id)
|
|
}
|
|
rows.Close()
|
|
if len(ids) > 0 {
|
|
log.Printf("⚠ Corrupt JSON detected: %s.%s in %d rows: %v",
|
|
t.table, t.column, len(ids), ids)
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
This doesn't repair anything — just surfaces problems in the startup
|
|
log so admins know. Repair is manual or via a future `--repair` flag.
|
|
|
|
### 17.2 Service Worker caches chrome-extension:// URLs
|
|
|
|
**Symptom:** `TypeError: Failed to execute 'put' on 'Cache': Request
|
|
scheme 'chrome-extension' is unsupported` on every page load.
|
|
|
|
**Root cause:** The SW fetch handler's exclusion checks are all
|
|
pathname-based (`/api/`, `/ws`, etc.). URLs from browser extensions
|
|
pass through and hit `Cache.put()` which rejects non-http(s) schemes.
|
|
|
|
**Fix:** Scheme guard at the top of the fetch handler:
|
|
```js
|
|
self.addEventListener('fetch', (event) => {
|
|
const url = new URL(event.request.url);
|
|
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
|
return; // Ignore chrome-extension://, moz-extension://, etc.
|
|
}
|
|
// ... existing handler ...
|
|
});
|
|
```
|
|
|
|
### 17.3 Branding 404 on custom.css
|
|
|
|
**Symptom:** `GET /branding/custom.css 404` on every page load when
|
|
no custom branding is configured.
|
|
|
|
**Fix:** Serve an empty CSS file as the default when no custom
|
|
branding is mounted. Either:
|
|
- (a) Backend: static handler returns empty `text/css` body with
|
|
`Cache-Control: no-cache` if file doesn't exist, or
|
|
- (b) Frontend: conditionally emit the `<link>` tag based on a
|
|
`has_custom_branding` flag in the health/settings response.
|
|
|
|
Option (a) is simpler — one handler change, no frontend coordination.
|
|
|
|
### 17.4 Duplicate PUT /settings during init
|
|
|
|
**Symptom:** Two `PUT /api/v1/settings` calls within ~50ms during
|
|
startup. Confirmed in server logs — two PUTs at 21:21:23 and again
|
|
at 21:21:36 (second page load).
|
|
|
|
**Root cause:** Two independent code paths saving settings on init.
|
|
|
|
**Fix:** Consolidate or debounce. A `settingsDirty` flag + 100ms
|
|
`setTimeout` debounce in the API layer ensures only one PUT fires
|
|
per init cycle.
|
|
|
|
### 17.5 WebSocket token in URL (minor security hygiene)
|
|
|
|
**Observation from server logs:** The WS endpoint logs include the
|
|
full JWT in the URL query string:
|
|
```
|
|
GET "/test/ws?token=eyJhbG..."
|
|
```
|
|
|
|
This means the JWT appears in: Gin access logs, nginx access logs,
|
|
and any log aggregator. Access tokens are short-lived (15 min) so
|
|
the risk is low, but it's worth:
|
|
- Redacting the token in Gin's log formatter (show `ws?token=[REDACTED]`)
|
|
- Or switching to WS subprotocol auth (send token as first message
|
|
after connect) in a future release
|
|
|
|
Not blocking for v0.19.0 but worth noting.
|