diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6bcbb17..b9f9d7f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,50 @@
All notable changes to Chat Switchboard.
+## [0.10.1] — 2026-02-24
+
+### Added
+- **Admin System Prompt** — Global system prompt configured in Admin › Settings.
+ Injected as the first system message in every conversation, before user/preset
+ system prompts. Users cannot override or disable it. Stored in
+ `global_settings['system_prompt']`.
+- **Personas tab** — User presets moved from the Models tab to their own
+ "Personas" tab in Settings. Tab is hidden when admin disables user presets
+ (`allow_user_personas` policy).
+- **Team Management modal** — Team admin functionality extracted from Settings
+ into its own tabbed modal (Members, Providers, Presets, Usage, Activity).
+ Accessed via "Team Management" flyout menu item, or "Manage →" on team cards
+ in Settings. Multi-team admins see a picker first; single-team admins go
+ straight to the tabbed view. Back arrow in header returns to picker.
+- **Preview pane: clear button** — Trash icon in the preview header resets the
+ iframe and shows the empty hint. Also auto-clears when deleting a chat that
+ had active preview content.
+- **Preview/Notes pane: fullscreen** — Expand button in the header toggles
+ fullscreen mode (panel fills entire viewport width).
+- **Preview/Notes pane: resizable** — Drag the left edge of the side panel
+ to resize (280px–70vw). Width resets on close.
+- **Code block: download** — "Download" button on every code block. Infers
+ file extension from language tag (e.g. `python` → `.py`, `go` → `.go`).
+
+### Changed
+- **Personal Usage scoped to BYOK** — `GET /usage` and the user Usage tab now
+ only show consumption against personal (BYOK) providers. Global provider
+ usage is the org's cost, not the user's. New `QueryByUserPersonal` store
+ method filters on `scope = 'personal' AND owner_id = user_id`. Admin usage
+ views remain unaffected. Usage tab hidden when admin disables user providers
+ (`allow_user_byok` policy).
+- **OpenAI streaming usage race** — Deferred the Done event until the usage
+ chunk arrives. Previously, finish_reason fired Done before the usage chunk
+ (which has `choices: []`), so streaming token counts were always 0 for all
+ OpenAI-compatible providers.
+
+### Fixed
+- **Usage logging zero-token guard** — Removed early exit in `logUsage` that
+ suppressed rows when tokens were 0. Combined with the streaming race above,
+ this meant no streaming usage was ever recorded.
+- **Live chat completion test** — `TestLive_VeniceChatCompletion` sent wrong
+ field names (`messages`/`config_id` instead of `content`/`provider_config_id`).
+
## [0.10.0] — 2026-02-24
### Added
diff --git a/server/handlers/admin.go b/server/handlers/admin.go
index 3a832d7..60b7a42 100644
--- a/server/handlers/admin.go
+++ b/server/handlers/admin.go
@@ -267,11 +267,19 @@ func (h *AdminHandler) PublicSettings(c *gin.Context) {
// Banner config, branding, etc. — safe subset for non-admin users
banner, _ := h.stores.GlobalConfig.Get(c.Request.Context(), "banner")
branding, _ := h.stores.GlobalConfig.Get(c.Request.Context(), "branding")
+ systemPrompt, _ := h.stores.GlobalConfig.Get(c.Request.Context(), "system_prompt")
policies, _ := h.stores.Policies.GetAll(c.Request.Context())
+ // Only tell the user whether admin prompt exists — don't expose content
+ hasAdminPrompt := false
+ if content, ok := systemPrompt["content"].(string); ok && content != "" {
+ hasAdminPrompt = true
+ }
+
c.JSON(http.StatusOK, gin.H{
- "banner": banner,
- "branding": branding,
+ "banner": banner,
+ "branding": branding,
+ "has_admin_prompt": hasAdminPrompt,
"policies": gin.H{
"allow_registration": policies["allow_registration"],
"allow_user_byok": policies["allow_user_byok"],
diff --git a/server/handlers/completion.go b/server/handlers/completion.go
index 1f4cfc0..a718e70 100644
--- a/server/handlers/completion.go
+++ b/server/handlers/completion.go
@@ -1,6 +1,7 @@
package handlers
import (
+ "context"
"database/sql"
"encoding/json"
"fmt"
@@ -480,14 +481,25 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
// active path through the message tree. Only the current branch is sent —
// sibling branches are never included in context.
func (h *CompletionHandler) loadConversation(channelID, userID, presetSystemPrompt string) ([]providers.Message, error) {
- // Load system prompt from channel
+ messages := make([]providers.Message, 0)
+
+ // ── Admin system prompt (always injected first, no opt out) ──
+ adminPrompt, err := h.stores.GlobalConfig.Get(context.Background(), "system_prompt")
+ if err == nil {
+ if content, ok := adminPrompt["content"].(string); ok && content != "" {
+ messages = append(messages, providers.Message{
+ Role: "system",
+ Content: content,
+ })
+ }
+ }
+
+ // ── User/preset system prompt (appended after admin prompt) ──
var systemPrompt *string
_ = database.DB.QueryRow(
`SELECT system_prompt FROM channels WHERE id = $1`, channelID,
).Scan(&systemPrompt)
- messages := make([]providers.Message, 0)
-
// Preset system prompt takes priority; channel system prompt is fallback
if presetSystemPrompt != "" {
messages = append(messages, providers.Message{
diff --git a/server/handlers/integration_test.go b/server/handlers/integration_test.go
index 94b92fd..60d7342 100644
--- a/server/handlers/integration_test.go
+++ b/server/handlers/integration_test.go
@@ -1861,21 +1861,35 @@ func TestIntegration_Usage_PersonalIncludesBYOK(t *testing.T) {
_, adminToken := h.createAdminUser("admin", "admin@test.com")
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
+ database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_user_byok', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
userID, userToken := h.registerUser("carol", "carol@test.com", "password123")
- // Create global provider
+ // Create global provider (admin-managed)
w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
"name": "Global2", "provider": "openai",
"endpoint": "https://api.openai.com/v1", "api_key": "sk-g2",
})
- var cfg map[string]interface{}
- decode(w, &cfg)
- cfgID := cfg["id"].(string)
+ var globalCfg map[string]interface{}
+ decode(w, &globalCfg)
+ globalCfgID := globalCfg["id"].(string)
- seedUsage(t, userID, cfgID, "gpt-4o", "global", 1000, 200, 0.01, 0.01)
- seedUsage(t, userID, cfgID, "gpt-4o", "personal", 500, 100, 0.005, 0.005)
+ // Create personal BYOK provider (user-managed)
+ w = h.request("POST", "/api/v1/api-configs", userToken, map[string]interface{}{
+ "name": "MyKey", "provider": "openai",
+ "endpoint": "http://localhost:1/v1", "api_key": "sk-user-carol",
+ })
+ if w.Code != http.StatusCreated {
+ t.Fatalf("create personal config: want 201, got %d: %s", w.Code, w.Body.String())
+ }
+ var personalCfg map[string]interface{}
+ decode(w, &personalCfg)
+ personalCfgID := personalCfg["id"].(string)
- // Personal view should include all scopes
+ // Seed usage against both providers
+ seedUsage(t, userID, globalCfgID, "gpt-4o", "global", 1000, 200, 0.01, 0.01)
+ seedUsage(t, userID, personalCfgID, "gpt-4o", "personal", 500, 100, 0.005, 0.005)
+
+ // Personal view should only show BYOK usage (not global)
w = h.request("GET", "/api/v1/usage", userToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("personal usage: %d: %s", w.Code, w.Body.String())
@@ -1884,8 +1898,26 @@ func TestIntegration_Usage_PersonalIncludesBYOK(t *testing.T) {
decode(w, &resp)
totals := resp["totals"].(map[string]interface{})
- if int(totals["requests"].(float64)) != 2 {
- t.Errorf("personal should see 2 requests (includes BYOK), got %v", totals["requests"])
+ if int(totals["requests"].(float64)) != 1 {
+ t.Errorf("personal should see 1 request (BYOK only, not global), got %v", totals["requests"])
+ }
+ if int(totals["input_tokens"].(float64)) != 500 {
+ t.Errorf("personal should see 500 input tokens (BYOK row), got %v", totals["input_tokens"])
+ }
+
+ // Admin view should show global usage but exclude BYOK
+ w = h.request("GET", "/api/v1/admin/usage", adminToken, nil)
+ if w.Code != http.StatusOK {
+ t.Fatalf("admin usage: %d: %s", w.Code, w.Body.String())
+ }
+ decode(w, &resp)
+ totals = resp["totals"].(map[string]interface{})
+
+ if int(totals["requests"].(float64)) != 1 {
+ t.Errorf("admin should see 1 request (global only, not BYOK), got %v", totals["requests"])
+ }
+ if int(totals["input_tokens"].(float64)) != 1000 {
+ t.Errorf("admin should see 1000 input tokens (global row), got %v", totals["input_tokens"])
}
}
diff --git a/server/handlers/usage.go b/server/handlers/usage.go
index 7863c80..a1b93ba 100644
--- a/server/handlers/usage.go
+++ b/server/handlers/usage.go
@@ -92,7 +92,7 @@ func (h *UsageHandler) PersonalUsage(c *gin.Context) {
return
}
- results, err := h.stores.Usage.QueryByUser(c.Request.Context(), userID, opts)
+ results, err := h.stores.Usage.QueryByUserPersonal(c.Request.Context(), userID, opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query usage"})
return
diff --git a/server/store/interfaces.go b/server/store/interfaces.go
index ebaeccf..e7ef712 100644
--- a/server/store/interfaces.go
+++ b/server/store/interfaces.go
@@ -285,6 +285,7 @@ type GlobalConfigStore interface {
type UsageStore interface {
Log(ctx context.Context, entry *models.UsageEntry) error
QueryByUser(ctx context.Context, userID string, opts UsageQueryOptions) ([]models.UsageAggregate, error)
+ QueryByUserPersonal(ctx context.Context, userID string, opts UsageQueryOptions) ([]models.UsageAggregate, error)
QueryByTeam(ctx context.Context, teamID string, opts UsageQueryOptions) ([]models.UsageAggregate, error)
QueryByTeamProviders(ctx context.Context, teamID string, opts UsageQueryOptions) ([]models.UsageAggregate, error)
QueryByModel(ctx context.Context, opts UsageQueryOptions) ([]models.UsageAggregate, error)
diff --git a/server/store/postgres/usage.go b/server/store/postgres/usage.go
index 5974614..ccb1e32 100644
--- a/server/store/postgres/usage.go
+++ b/server/store/postgres/usage.go
@@ -40,6 +40,16 @@ func (s *UsageStore) QueryByUser(ctx context.Context, userID string, opts store.
return s.query(ctx, where, args, opts)
}
+// QueryByUserPersonal returns aggregated usage for a user's own (BYOK) providers only.
+func (s *UsageStore) QueryByUserPersonal(ctx context.Context, userID string, opts store.UsageQueryOptions) ([]models.UsageAggregate, error) {
+ where := []string{
+ "user_id = $1",
+ "provider_config_id IN (SELECT id FROM provider_configs WHERE scope = 'personal' AND owner_id = $1)",
+ }
+ args := []interface{}{userID}
+ return s.query(ctx, where, args, opts)
+}
+
// QueryByTeam returns aggregated usage for all members of a team (admin view).
func (s *UsageStore) QueryByTeam(ctx context.Context, teamID string, opts store.UsageQueryOptions) ([]models.UsageAggregate, error) {
where := []string{"user_id IN (SELECT user_id FROM team_members WHERE team_id = $1)"}
@@ -104,11 +114,15 @@ func (s *UsageStore) GetTeamProviderTotals(ctx context.Context, teamID string, o
return &t, err
}
-// GetPersonalTotals returns aggregate totals for a specific user (all scopes).
+// GetPersonalTotals returns aggregate totals for a user's own provider usage.
+// Only includes usage against personal (BYOK) providers — global provider
+// costs are the org's responsibility, not the user's.
func (s *UsageStore) GetPersonalTotals(ctx context.Context, userID string, opts store.UsageQueryOptions) (*models.UsageTotals, error) {
- baseWhere := []string{"user_id = $1"}
+ baseWhere := []string{
+ "user_id = $1",
+ "provider_config_id IN (SELECT id FROM provider_configs WHERE scope = 'personal' AND owner_id = $1)",
+ }
baseArgs := []interface{}{userID}
- // Don't exclude BYOK for personal view
opts.ExcludeBYOK = false
where, args := s.buildFilters(baseWhere, baseArgs, opts)
diff --git a/src/css/styles.css b/src/css/styles.css
index c17f420..ffba3be 100644
--- a/src/css/styles.css
+++ b/src/css/styles.css
@@ -579,17 +579,38 @@ a:hover { text-decoration: underline; }
.side-panel {
width: 0; min-width: 0; overflow: hidden;
background: var(--bg); border-left: 1px solid var(--border);
- display: flex; flex-direction: column;
+ display: flex; flex-direction: column; position: relative;
transition: width 0.25s ease, min-width 0.25s ease;
}
.side-panel.open {
width: 480px; min-width: 480px;
}
+.side-panel.fullscreen {
+ position: fixed; top: 0; right: 0; bottom: 0;
+ width: 100% !important; min-width: 100% !important;
+ z-index: 100;
+ transition: none;
+}
+.side-panel-resize {
+ position: absolute; left: -3px; top: 0; bottom: 0; width: 6px;
+ cursor: col-resize; z-index: 10;
+}
+.side-panel-resize:hover { background: var(--accent); opacity: 0.3; }
+.side-panel.fullscreen .side-panel-resize { display: none; }
.side-panel-header {
display: flex; align-items: center; justify-content: space-between;
padding: 8px 12px; border-bottom: 1px solid var(--border);
background: var(--bg-raised); flex-shrink: 0;
}
+.side-panel-actions {
+ display: flex; align-items: center; gap: 2px;
+}
+.side-panel-action-btn {
+ background: none; border: none; color: var(--text-3);
+ cursor: pointer; padding: 4px 6px; border-radius: var(--radius);
+ transition: all var(--transition); display: flex; align-items: center;
+}
+.side-panel-action-btn:hover { background: var(--bg-hover); color: var(--text); }
.side-panel-tabs {
display: flex; gap: 2px; background: var(--bg-surface);
border-radius: var(--radius); padding: 2px;
@@ -1279,6 +1300,12 @@ button { font-family: var(--font); cursor: pointer; }
.btn-approve { background: rgba(34,197,94,0.15); color: #4ade80; border: 1px solid rgba(34,197,94,0.3); border-radius: 4px; padding: 3px 10px; cursor: pointer; font-size: 12px; }
.team-card { padding: 8px 10px; background: rgba(255,255,255,0.03); border-radius: 6px; margin-bottom: 6px; }
.team-card-info { display: flex; align-items: center; gap: 8px; }
+.team-admin-back {
+ cursor: pointer; margin-right: 4px; opacity: 0.5;
+ transition: opacity var(--transition);
+}
+.team-admin-back:hover { opacity: 1; }
+.team-tab-content { padding: 0; }
.badge-private { background: rgba(139,92,246,0.85); color: #fff; font-size: 10px; padding: 1px 8px; border-radius: 4px; }
.admin-user-row.user-inactive { opacity: 0.7; }
.loading { color: var(--text-3); font-size: 13px; padding: 0.5rem; }
diff --git a/src/index.html b/src/index.html
index a44a6cb..e548b47 100644
--- a/src/index.html
+++ b/src/index.html
@@ -95,6 +95,10 @@
Admin Panel
+