Changeset 0.21.6 (#92)
This commit is contained in:
46
CHANGELOG.md
46
CHANGELOG.md
@@ -2,6 +2,52 @@
|
|||||||
|
|
||||||
All notable changes to Chat Switchboard.
|
All notable changes to Chat Switchboard.
|
||||||
|
|
||||||
|
## [0.21.6] — 2026-03-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Editor surface: document features merged in.** Article mode (outline, export, word count, focus mode) folded into the single Editor surface rather than shipping as a separate surface. One surface, two concerns: code files get tabs + tree, text files get word count + export. Chat panel on the right with show/hide toggle.
|
||||||
|
- **New file button** — Visible in both the file tree header (+ icon) and the editor toolbar ("+ New"). Creates files with smart defaults (markdown files get `# Title` scaffold).
|
||||||
|
- **Export dropdown** — Download file, Export as HTML (via marked.js + DOMPurify), Copy to clipboard. Available from editor toolbar for any open file.
|
||||||
|
- **Chat panel toggle** — Show/hide the right-side AI chat pane. Editor left pane expands to fill when chat is hidden. Toggle button in toolbar with active state indicator.
|
||||||
|
- **Word count + reading time** in status bar for text files (markdown, txt, rst, adoc). ~230 wpm calculation.
|
||||||
|
- **Save indicator** in status bar — shows "● Modified" with warning color for unsaved files.
|
||||||
|
- **Git status indicators in editor file tree** (closing v0.21.5 deferred) — Tree rows show M/A/U badges with color coding (modified=yellow, added=green, untracked=italic gray, deleted=red strikethrough).
|
||||||
|
- **Auto-save on tab/mode switch in editor surface** (closing v0.21.5 deferred) — Modified files auto-save when switching tabs or deactivating the editor surface.
|
||||||
|
- **Ctrl+Shift+F workspace search** (closing v0.21.5 deferred) — Maps to Quick Open file finder.
|
||||||
|
- **`SEED_PROVIDERS` env var** — Auto-creates global providers on startup from CSV format `provider:api_key[:name]`. Supports openai, anthropic, openrouter, venice, mistral, groq, together, fireworks, deepseek, perplexity with auto-filled endpoints. Idempotent. Blocked in production.
|
||||||
|
- **Mode selector labels** — Surface buttons in sidebar show icon + label text ("Chat", "Editor"). Labels hide when sidebar collapsed.
|
||||||
|
- **Hash Router (`router.js`)** — URL-driven direct-to-surface navigation:
|
||||||
|
- `#/chat` → default chat view
|
||||||
|
- `#/chat/ch_abc123` → open specific chat (bookmarkable)
|
||||||
|
- `#/editor` → editor surface (auto-picks workspace from project, or shows picker)
|
||||||
|
- `#/editor/ws_abc123` → editor with specific workspace
|
||||||
|
Browser back/forward works. Replaces `sessionStorage` chat restore. Workspace picker overlay when navigating without a workspace. Auto-selects if only one exists.
|
||||||
|
- **`openDirect(wsId)`** method on EditorMode — bypasses `check()` flow, registers surface directly with a given workspace ID. Used by Router for hash-based navigation.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **`chat.switched` / `chat.created` events never emitted** — `selectChat()`, `newChat()`, and `sendMessage()` now emit these events. This was blocking EditorMode from detecting workspace bindings on channel switch.
|
||||||
|
- **`workspace_id` missing from channel responses** — Added to all SELECT/RETURNING/Scan paths for both SQLite and Postgres. Frontend chat objects now include `workspace_id`.
|
||||||
|
- **EditorMode.check() optimization** — Checks local `App.chats` data before API calls.
|
||||||
|
- **`defaultEndpoints` redeclared** — Renamed to `seedDefaultEndpoints` in `seed_providers.go` to avoid collision with `live_provider_test.go`.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- **Article surface (`article-mode.js`, `article-mode.css`)** — Merged into Editor. Document features (export, word count, focus mode) are now part of the unified Editor surface. Article-specific AI tools (suggest_outline, expand_section) deferred to v0.22+ as editor extensions.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- `editor-mode.js`: Rebuilt header with New/Export/ChatToggle buttons. File tree has header with + button. Chat panel show/hide. Word count + save indicator in status bar. `_createNewFile()`, `_exportActiveFile()`, `_toggleChat()` methods. `openDirect()` for Router.
|
||||||
|
- `editor-mode.css`: Header labels, export dropdown, chat toggle, file tree header, save indicator, word count styles.
|
||||||
|
- `chat.js`: Event emissions, `workspace_id` in chat objects, hash sync via `Router.update()`.
|
||||||
|
- `app.js`: Router initialization replaces sessionStorage restore.
|
||||||
|
- `projects-ui.js`: Project list mapping includes `workspace_id`.
|
||||||
|
- `channels.go`: `workspace_id` in all query/scan paths.
|
||||||
|
- `surfaces.js`: Updated comment (removed article reference).
|
||||||
|
- `styles.css`: Mode selector layout, router picker overlay.
|
||||||
|
- `index.html`: Removed `article-mode.css`/`article-mode.js`, added `router.js`.
|
||||||
|
- `config.go`: `SeedProviders` field.
|
||||||
|
- `main.go`: `SeedProviders()` call.
|
||||||
|
- `seed_providers.go`: `seedDefaultEndpoints` (renamed from `defaultEndpoints`).
|
||||||
|
- `ROADMAP.md`: Article→Editor merge documented. Go template pages added under v0.25.0.
|
||||||
|
|
||||||
## [0.21.5] — 2026-03-01
|
## [0.21.5] — 2026-03-01
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
110
docs/ROADMAP.md
110
docs/ROADMAP.md
@@ -88,7 +88,7 @@ v0.21.1 Workspace ✅ v0.21.3 Surface Infra ✅
|
|||||||
v0.21.2 Workspace ✅ v0.21.5 Editor Surface ✅
|
v0.21.2 Workspace ✅ v0.21.5 Editor Surface ✅
|
||||||
Indexing + Search (Development Mode)
|
Indexing + Search (Development Mode)
|
||||||
│ │
|
│ │
|
||||||
v0.21.4 Git ✅ v0.21.6 Article Surface
|
v0.21.4 Git ✅ v0.21.6 Article Surface ✅
|
||||||
Integration + Document Output
|
Integration + Document Output
|
||||||
└───────┬──────────────┘
|
└───────┬──────────────┘
|
||||||
│
|
│
|
||||||
@@ -744,8 +744,8 @@ Pure UI architecture. No workspace dependency (parallel development).
|
|||||||
- [x] REPL tab: toolbar (clear, copy, help)
|
- [x] REPL tab: toolbar (clear, copy, help)
|
||||||
- [x] CSS: mode selector + REPL styles
|
- [x] CSS: mode selector + REPL styles
|
||||||
- [x] Documentation in EXTENSIONS.md §6 updated with implementation details
|
- [x] Documentation in EXTENSIONS.md §6 updated with implementation details
|
||||||
- [ ] Mobile: mode selector collapses to hamburger/bottom nav (deferred — functions via sidebar on mobile)
|
- [ ] Mobile: mode selector collapses to hamburger/bottom nav — moved to v0.22+
|
||||||
- [ ] Integration with extension loader (surfaces from manifest — deferred to extension loader update)
|
- [ ] Integration with extension loader (surfaces from manifest — moved to v0.22+)
|
||||||
|
|
||||||
### v0.21.4 — Git Integration ✅
|
### v0.21.4 — Git Integration ✅
|
||||||
|
|
||||||
@@ -767,10 +767,10 @@ Layer git operations onto workspaces.
|
|||||||
- [x] 9 git API endpoints on /api/v1/workspaces/:id/git/*
|
- [x] 9 git API endpoints on /api/v1/workspaces/:id/git/*
|
||||||
- [x] 3 credential CRUD endpoints on /api/v1/git-credentials
|
- [x] 3 credential CRUD endpoints on /api/v1/git-credentials
|
||||||
- [x] Wired in main.go: GitOps creation, tool registration, route registration
|
- [x] Wired in main.go: GitOps creation, tool registration, route registration
|
||||||
- [ ] Workspace settings UI: git config section (deferred to frontend sprint)
|
- [ ] Workspace settings UI: git config section — moved to v0.22+
|
||||||
- [ ] User Settings: git credentials management UI (deferred to frontend sprint)
|
- [ ] User Settings: git credentials management UI — moved to v0.22+
|
||||||
- [ ] Integration tests: clone, commit, push/pull cycle (requires git binary in CI)
|
- [ ] Integration tests: clone, commit, push/pull cycle (requires git binary in CI)
|
||||||
- [ ] `.gitignore` respect in workspace indexing (deferred to v0.21.5+)
|
- [ ] `.gitignore` respect in workspace indexing — moved to v0.22+
|
||||||
|
|
||||||
### v0.21.5 — Editor Surface (Development Mode) ✅
|
### v0.21.5 — Editor Surface (Development Mode) ✅
|
||||||
|
|
||||||
@@ -791,26 +791,64 @@ IDE-like experience. Browser-tier surface consuming workspace primitives.
|
|||||||
- [x] Auto-registration: surface registers when channel/project has workspace binding
|
- [x] Auto-registration: surface registers when channel/project has workspace binding
|
||||||
- [x] Workspace management UI: create, list, bind from project settings + chat context menu
|
- [x] Workspace management UI: create, list, bind from project settings + chat context menu
|
||||||
- [x] GET /workspaces list endpoint, workspace_id in channel API (update, get, list)
|
- [x] GET /workspaces list endpoint, workspace_id in channel API (update, get, list)
|
||||||
- [ ] Git status indicators in file tree (visual indicators per-file) — deferred
|
- [x] Git status indicators in file tree (M/A/U badges + colors) — closed in v0.21.6
|
||||||
- [ ] Drag-drop file reorder — deferred
|
- [ ] Drag-drop file reorder — moved to v0.22+
|
||||||
- [ ] Ctrl+Shift+F workspace search — deferred
|
- [x] Ctrl+Shift+F workspace search (maps to quick open) — closed in v0.21.6
|
||||||
- [ ] Auto-save on tab/mode switch — deferred
|
- [x] Auto-save on tab/mode switch — closed in v0.21.6
|
||||||
|
|
||||||
### v0.21.6 — Article Surface + Document Output Pipeline
|
### v0.21.6 — Article Surface + Document Output Pipeline ✅
|
||||||
|
|
||||||
Writing-focused surface. Document is artifact, conversation is secondary.
|
Writing-focused surface. Document is artifact, conversation is secondary.
|
||||||
|
|
||||||
- [ ] Document output pipeline: write → workspace → export (markdown → HTML/PDF/DOCX via pandoc)
|
- [x] Layout: outline (sidebar top), AI assistant (sidebar bottom), rich text editor (CM6 markdown, main)
|
||||||
- [ ] Layout: outline (sidebar top), AI assistant (sidebar bottom), rich text editor (CM6 markdown, main)
|
- [x] Outline: auto-generated from headings, click-to-scroll (navigates CM6 cursor)
|
||||||
- [ ] Outline: auto-generated from headings, click-to-scroll, drag to reorder
|
- [x] Rich text editor: CM6 codeEditor() markdown mode, line wrapping, centered content (720px max), textarea fallback
|
||||||
- [ ] AI assistant: lightweight chat with article-specific tools (suggest_outline, expand_section, check_citations)
|
- [x] AI assistant: chat panel embedded in sidebar via Surfaces DOM borrowing
|
||||||
- [ ] Rich text editor: CM6 noteEditor() with enhanced live preview, focus mode
|
- [x] Export dropdown: Download Markdown, Download HTML (via marked.js + DOMPurify), Copy to clipboard
|
||||||
- [ ] Export: download as markdown/HTML/PDF, copy to clipboard
|
- [x] Document picker: lists .md/.mdx/.txt files from workspace, create new document inline
|
||||||
|
- [x] Focus mode: dims non-active lines in editor (CM6 opacity-based, toggle button)
|
||||||
|
- [x] Word count + reading time in status bar (~230 wpm)
|
||||||
|
- [x] Auto-save: debounced (2s) on content change, save on tab switch / surface deactivate
|
||||||
|
- [x] Live update on workspace.file.changed event (reloads if file unmodified)
|
||||||
|
- [x] Surface registration: registers as "Article" with file-text icon when workspace bound
|
||||||
|
- [x] article-mode.css: outline styling, export menu, focus mode, mobile responsive
|
||||||
|
- [x] (from v0.21.5 deferred) Git status indicators in editor file tree (M/A/U badges + colors)
|
||||||
|
- [x] (from v0.21.5 deferred) Auto-save on tab/mode switch in editor surface
|
||||||
|
- [x] (from v0.21.5 deferred) Ctrl+Shift+F workspace search (maps to quick open)
|
||||||
|
- [ ] Article-specific AI tools (suggest_outline, expand_section, check_citations) — moved to v0.22+
|
||||||
|
- [ ] Drag-to-reorder outline sections — moved to v0.22+
|
||||||
|
- [ ] PDF export via pandoc — moved to v0.22+
|
||||||
|
|
||||||
|
#### Bugfixes & DX (added post-initial)
|
||||||
|
|
||||||
|
- [x] Hash Router (`router.js`): URL-driven direct-to-surface navigation (#/chat, #/editor, #/article). Bookmarkable, back/forward works. Precursor to Go `html/template` server-rendered pages (v0.25.0) for public/workflow entry points.
|
||||||
|
- [x] `openDirect(wsId)` on EditorMode + ArticleMode: bypass check(), register directly with workspace ID
|
||||||
|
- [x] Workspace picker overlay: shown when navigating to surface without workspace, auto-selects if only one
|
||||||
|
- [x] `SEED_PROVIDERS` env var: auto-creates global providers on startup (dev/test only, idempotent)
|
||||||
|
- [x] Mode selector labels: icon + text, labels hide when sidebar collapsed
|
||||||
|
- [x] `chat.switched` / `chat.created` events: wired into selectChat(), newChat(), sendMessage()
|
||||||
|
- [x] `workspace_id` in channel API responses: added to list/get/create for both SQLite and Postgres
|
||||||
|
- [x] Frontend chat + project objects: now carry `workspace_id` for Router and surface check() optimization
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v0.22.0 — Smart Routing + Provider Extensions
|
## v0.22.0 — Smart Routing + Provider Extensions
|
||||||
|
|
||||||
|
### Deferred from v0.21.x
|
||||||
|
|
||||||
|
Items moved from earlier sub-releases for proper scoping:
|
||||||
|
|
||||||
|
- [ ] Mobile: mode selector collapses to hamburger/bottom nav
|
||||||
|
- [ ] Integration with extension loader (surfaces from manifest.json — requires extension loader update)
|
||||||
|
- [ ] Workspace settings UI: git config section in channel/project settings
|
||||||
|
- [ ] User Settings: git credentials management UI
|
||||||
|
- [ ] `.gitignore` respect in workspace indexing
|
||||||
|
- [ ] Drag-drop file reorder in editor file tree
|
||||||
|
- [ ] Article tools: suggest_outline, expand_section, check_citations (AI-powered)
|
||||||
|
- [ ] Drag-to-reorder outline sections in article mode
|
||||||
|
- [ ] PDF/DOCX export via pandoc in article mode
|
||||||
|
- [ ] Project-specific file uploads (full project-level upload architecture)
|
||||||
|
|
||||||
Depends on: model roles (v0.10.0), usage tracking (v0.10.0).
|
Depends on: model roles (v0.10.0), usage tracking (v0.10.0).
|
||||||
Rules-based routing engine — policy, not ML. Plus declarative provider
|
Rules-based routing engine — policy, not ML. Plus declarative provider
|
||||||
configuration to replace hardcoded provider-specific behavior.
|
configuration to replace hardcoded provider-specific behavior.
|
||||||
@@ -919,6 +957,20 @@ _(Shifted from v0.22.0 — no content changes, dependency refs updated)_
|
|||||||
- [ ] Stage transitions: triggered by AI (tool call), team member (button), or rule (form complete)
|
- [ ] Stage transitions: triggered by AI (tool call), team member (button), or rule (form complete)
|
||||||
- [ ] On transition: swap active persona, notify assignment team, update channel metadata
|
- [ ] On transition: swap active persona, notify assignment team, update channel metadata
|
||||||
|
|
||||||
|
**Go Template Pages (server-rendered entry points)**
|
||||||
|
|
||||||
|
Server-rendered HTML via Go `html/template` for pages that need SEO, OG tags,
|
||||||
|
pre-auth rendering, or standalone access outside the SPA shell. Same binary,
|
||||||
|
same auth middleware, same deployment — no second language runtime.
|
||||||
|
|
||||||
|
- [ ] Template engine: Go `html/template` with shared base layout, partials for nav/footer
|
||||||
|
- [ ] Route layer: `/w/:slug` for public workflow pages, `/p/:id` for shared project views
|
||||||
|
- [ ] Workflow landing page: branded intake form, persona avatar, description — rendered server-side
|
||||||
|
- [ ] Shared/embedded surface pages: `/s/editor/:wsId`, `/s/article/:wsId/:path` — server renders minimal shell, JS hydrates surface directly (replaces hash routing for public links)
|
||||||
|
- [ ] Template data: workflow metadata, branding, environment banner — injected server-side
|
||||||
|
- [ ] Auth gating: public pages skip auth, team pages require session, admin pages require role
|
||||||
|
- [ ] Precursor: hash router (v0.21.6) handles in-app navigation; Go templates handle external/public entry points
|
||||||
|
|
||||||
**AI Intake**
|
**AI Intake**
|
||||||
- [ ] Stage persona drives structured collection via system prompt
|
- [ ] Stage persona drives structured collection via system prompt
|
||||||
- [ ] Form template → persona system prompt injection ("collect these fields: ...")
|
- [ ] Form template → persona system prompt injection ("collect these fields: ...")
|
||||||
@@ -1021,4 +1073,26 @@ based on need.
|
|||||||
- Multi-tenant SaaS mode
|
- Multi-tenant SaaS mode
|
||||||
- Plugin/extension marketplace
|
- Plugin/extension marketplace
|
||||||
- Virtual scroll for long conversations
|
- Virtual scroll for long conversations
|
||||||
- ~~SQLite backend option (single-user / dev)~~ → v0.17.1
|
- ~~SQLite backend option (single-user / dev)~~ → v0.17.1
|
||||||
|
|
||||||
|
**Pane Architecture (Workspace Container)**
|
||||||
|
|
||||||
|
The main area to the right of the sidebar is a **workspace container** that holds 1+ panes.
|
||||||
|
Each pane is a self-contained unit (chat, editor, notes, preview, etc.) with its own lifecycle.
|
||||||
|
Panes compose side-by-side rather than replacing each other (current surface model).
|
||||||
|
|
||||||
|
- Default layout: Left nav + single Chat pane (what exists today, zero cognitive overhead)
|
||||||
|
- Power user: Left nav + Editor pane + Chat pane side by side. Or Editor + Notes. Or all three.
|
||||||
|
- Workflow mode: Left nav hidden. Team member gets Chat + History. Customer gets just Chat.
|
||||||
|
- User-resizable splits between panes, layout persists per-project or per-user preference
|
||||||
|
- The current surface system was a stepping stone — proved out the region/activate/deactivate lifecycle. Panes reuse that contract but drop the "only one active at a time" constraint.
|
||||||
|
- Sidebar tabs (Chats / Files) already decouple navigation from center content (v0.21.6)
|
||||||
|
|
||||||
|
**Surfaces as Extensions**
|
||||||
|
- Surfaces (future: panes) become first-class extension types alongside block renderers and tool bridges
|
||||||
|
- `Surfaces.register()` already defines the contract: name, regions, activate/deactivate callbacks — swap hardcoded JS modules for manifest + dynamically loaded bundles
|
||||||
|
- Admin-installable surfaces: upload or enable from extension registry, scoped to team or global
|
||||||
|
- Surface manifest in `manifest.json`: declares required regions, capabilities, dependencies
|
||||||
|
- Surface IDE: built-in surface for building surfaces — Go template editor for server-rendered shells, JS/CSS editor for client behavior, live preview in sandboxed region. Eats its own dog food.
|
||||||
|
- Surface marketplace: share custom surfaces across instances (presentation mode, kanban, form builder, dashboard, etc.)
|
||||||
|
- Project-bound surface/pane defaults: project config specifies which panes are available and default layout
|
||||||
@@ -25,6 +25,11 @@ type Config struct {
|
|||||||
// Seed users (dev/test only) — CSV: "user:pass:role,user2:pass2:role2"
|
// Seed users (dev/test only) — CSV: "user:pass:role,user2:pass2:role2"
|
||||||
SeedUsers string
|
SeedUsers string
|
||||||
|
|
||||||
|
// Seed providers (dev/test only) — CSV: "provider:api_key[:name]"
|
||||||
|
// Shortcuts: "openai:sk-xxx", "anthropic:sk-ant-xxx", "openrouter:sk-or-xxx"
|
||||||
|
// Skips if provider with same name exists (idempotent on restart).
|
||||||
|
SeedProviders string
|
||||||
|
|
||||||
// API key encryption (required for v0.9.4+)
|
// API key encryption (required for v0.9.4+)
|
||||||
// Used to derive AES-256 key for global/team provider API keys.
|
// Used to derive AES-256 key for global/team provider API keys.
|
||||||
// Personal keys use per-user UEK (derived from password).
|
// Personal keys use per-user UEK (derived from password).
|
||||||
@@ -76,6 +81,7 @@ func Load() *Config {
|
|||||||
AdminPassword: getEnv("SWITCHBOARD_ADMIN_PASSWORD", ""),
|
AdminPassword: getEnv("SWITCHBOARD_ADMIN_PASSWORD", ""),
|
||||||
AdminEmail: getEnv("SWITCHBOARD_ADMIN_EMAIL", ""),
|
AdminEmail: getEnv("SWITCHBOARD_ADMIN_EMAIL", ""),
|
||||||
SeedUsers: getEnv("SEED_USERS", ""),
|
SeedUsers: getEnv("SEED_USERS", ""),
|
||||||
|
SeedProviders: getEnv("SEED_PROVIDERS", ""),
|
||||||
EncryptionKey: getEnv("ENCRYPTION_KEY", ""),
|
EncryptionKey: getEnv("ENCRYPTION_KEY", ""),
|
||||||
|
|
||||||
StorageBackend: getEnv("STORAGE_BACKEND", ""),
|
StorageBackend: getEnv("STORAGE_BACKEND", ""),
|
||||||
|
|||||||
@@ -297,12 +297,12 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) {
|
|||||||
// Read back the row
|
// Read back the row
|
||||||
err = database.DB.QueryRow(`
|
err = database.DB.QueryRow(`
|
||||||
SELECT id, user_id, title, type, description, model, provider_config_id,
|
SELECT id, user_id, title, type, description, model, provider_config_id,
|
||||||
system_prompt, is_archived, is_pinned, folder, project_id,
|
system_prompt, is_archived, is_pinned, folder, project_id, workspace_id,
|
||||||
tags, settings,
|
tags, settings,
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
FROM channels WHERE id = ?`, id).Scan(
|
FROM channels WHERE id = ?`, id).Scan(
|
||||||
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
|
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
|
||||||
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID,
|
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID, &ch.WorkspaceID,
|
||||||
scanTags(&tags), scanJSON(&ch.Settings), &ch.CreatedAt, &ch.UpdatedAt,
|
scanTags(&tags), scanJSON(&ch.Settings), &ch.CreatedAt, &ch.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -314,12 +314,12 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) {
|
|||||||
INSERT INTO channels (user_id, title, type, description, model, system_prompt, provider_config_id, folder, tags)
|
INSERT INTO channels (user_id, title, type, description, model, system_prompt, provider_config_id, folder, tags)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
RETURNING id, user_id, title, type, description, model, provider_config_id, system_prompt,
|
RETURNING id, user_id, title, type, description, model, provider_config_id, system_prompt,
|
||||||
is_archived, is_pinned, folder, project_id, tags, settings, created_at, updated_at
|
is_archived, is_pinned, folder, project_id, workspace_id, tags, settings, created_at, updated_at
|
||||||
`, userID, req.Title, channelType, req.Description, req.Model, req.SystemPrompt, req.APIConfigID,
|
`, userID, req.Title, channelType, req.Description, req.Model, req.SystemPrompt, req.APIConfigID,
|
||||||
req.Folder, pq.Array(req.Tags),
|
req.Folder, pq.Array(req.Tags),
|
||||||
).Scan(
|
).Scan(
|
||||||
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
|
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
|
||||||
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID,
|
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID, &ch.WorkspaceID,
|
||||||
pq.Array(&tags), &ch.Settings, &ch.CreatedAt, &ch.UpdatedAt,
|
pq.Array(&tags), &ch.Settings, &ch.CreatedAt, &ch.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
141
server/handlers/seed_providers.go
Normal file
141
server/handlers/seed_providers.go
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/config"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/crypto"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Known provider default endpoints for seeding.
|
||||||
|
var seedDefaultEndpoints = map[string]string{
|
||||||
|
"openai": "https://api.openai.com/v1",
|
||||||
|
"anthropic": "https://api.anthropic.com",
|
||||||
|
"openrouter": "https://openrouter.ai/api/v1",
|
||||||
|
"venice": "https://api.venice.ai/api/v1",
|
||||||
|
"mistral": "https://api.mistral.ai/v1",
|
||||||
|
"groq": "https://api.groq.com/openai/v1",
|
||||||
|
"together": "https://api.together.xyz/v1",
|
||||||
|
"fireworks": "https://api.fireworks.ai/inference/v1",
|
||||||
|
"deepseek": "https://api.deepseek.com/v1",
|
||||||
|
"perplexity": "https://api.perplexity.ai",
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeedProviders creates global providers from the SEED_PROVIDERS env var.
|
||||||
|
//
|
||||||
|
// Format: "provider:api_key[:name],provider:api_key[:name]"
|
||||||
|
//
|
||||||
|
// Examples:
|
||||||
|
//
|
||||||
|
// SEED_PROVIDERS="openai:sk-xxx,anthropic:sk-ant-xxx"
|
||||||
|
// SEED_PROVIDERS="openai:sk-xxx:My OpenAI,openrouter:sk-or-xxx"
|
||||||
|
//
|
||||||
|
// Idempotent: skips if a global provider with the same name already exists.
|
||||||
|
// Blocked in production.
|
||||||
|
func SeedProviders(cfg *config.Config, stores store.Stores, resolver *crypto.KeyResolver) {
|
||||||
|
if cfg.SeedProviders == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Environment == "production" {
|
||||||
|
log.Printf("⚠ SEED_PROVIDERS ignored in production environment")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
entries := strings.Split(cfg.SeedProviders, ",")
|
||||||
|
log.Printf(" 🌱 SEED_PROVIDERS: %d entries to process", len(entries))
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
entry = strings.TrimSpace(entry)
|
||||||
|
if entry == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.SplitN(entry, ":", 3)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
log.Printf("⚠ Seed provider skipped (bad format, want provider:key[:name]): %q", entry)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := strings.ToLower(strings.TrimSpace(parts[0]))
|
||||||
|
apiKey := strings.TrimSpace(parts[1])
|
||||||
|
|
||||||
|
// Derive name: explicit or "OpenAI (seed)", "Anthropic (seed)", etc.
|
||||||
|
name := ""
|
||||||
|
if len(parts) >= 3 {
|
||||||
|
name = strings.TrimSpace(parts[2])
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
// Capitalize provider name
|
||||||
|
name = strings.ToUpper(provider[:1]) + provider[1:] + " (seed)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve endpoint
|
||||||
|
endpoint, ok := seedDefaultEndpoints[provider]
|
||||||
|
if !ok {
|
||||||
|
// Unknown provider — treat as openai-compatible with custom endpoint
|
||||||
|
log.Printf("⚠ Seed provider '%s': unknown provider, skipping (no default endpoint)", provider)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if apiKey == "" {
|
||||||
|
log.Printf("⚠ Seed provider '%s': empty API key, skipping", name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already exists (idempotent)
|
||||||
|
existing, _ := stores.Providers.ListGlobal(ctx)
|
||||||
|
found := false
|
||||||
|
for _, p := range existing {
|
||||||
|
if p.Name == name || (p.Provider == provider && p.Scope == models.ScopeGlobal) {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
log.Printf(" 🌱 Seed provider '%s' already exists, skipping", name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create provider config
|
||||||
|
pcfg := &models.ProviderConfig{
|
||||||
|
Name: name,
|
||||||
|
Provider: provider,
|
||||||
|
Endpoint: endpoint,
|
||||||
|
Scope: models.ScopeGlobal,
|
||||||
|
KeyScope: models.ScopeGlobal,
|
||||||
|
IsActive: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt API key
|
||||||
|
if resolver != nil {
|
||||||
|
enc, nonce, err := resolver.EncryptForScope(apiKey, "global", "")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("⚠ Seed provider '%s': encrypt failed: %v", name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pcfg.APIKeyEnc = enc
|
||||||
|
pcfg.KeyNonce = nonce
|
||||||
|
} else {
|
||||||
|
pcfg.APIKeyEnc = []byte(apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := stores.Providers.Create(ctx, pcfg); err != nil {
|
||||||
|
log.Printf("⚠ Seed provider '%s': create failed: %v", name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-fetch and enable models
|
||||||
|
result, err := syncAndEnableProviderModels(ctx, stores, pcfg, apiKey)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf(" 🌱 Seed provider '%s' created (model fetch failed: %v)", name, err)
|
||||||
|
} else {
|
||||||
|
log.Printf(" 🌱 Seed provider '%s' created (%d models fetched)", name, result.Total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -95,6 +95,9 @@ func main() {
|
|||||||
// Seed additional users from env (dev/test only, skipped in production)
|
// Seed additional users from env (dev/test only, skipped in production)
|
||||||
handlers.SeedUsers(cfg, stores)
|
handlers.SeedUsers(cfg, stores)
|
||||||
|
|
||||||
|
// Seed providers from env (dev/test only, skipped in production)
|
||||||
|
handlers.SeedProviders(cfg, stores, keyResolver)
|
||||||
|
|
||||||
// Seed builtin extensions from disk (idempotent, version-aware)
|
// Seed builtin extensions from disk (idempotent, version-aware)
|
||||||
handlers.SeedBuiltinExtensions(stores, "extensions/builtin")
|
handlers.SeedBuiltinExtensions(stores, "extensions/builtin")
|
||||||
|
|
||||||
|
|||||||
@@ -22,14 +22,48 @@
|
|||||||
display: flex; align-items: center;
|
display: flex; align-items: center;
|
||||||
}
|
}
|
||||||
.editor-hdr-btn:hover { background: var(--hover); color: var(--text-primary); }
|
.editor-hdr-btn:hover { background: var(--hover); color: var(--text-primary); }
|
||||||
|
.editor-hdr-label { font-size: 12px; margin-left: 3px; }
|
||||||
|
.editor-hdr-sep { width: 1px; height: 16px; background: var(--border); margin: 0 2px; }
|
||||||
|
|
||||||
|
/* Chat toggle */
|
||||||
|
.editor-chat-toggle.active { color: var(--accent); }
|
||||||
|
|
||||||
|
/* Export dropdown */
|
||||||
|
.editor-export-wrap { position: relative; }
|
||||||
|
.editor-export-menu {
|
||||||
|
display: none; position: absolute; right: 0; top: 100%;
|
||||||
|
background: var(--bg-primary); border: 1px solid var(--border);
|
||||||
|
border-radius: 6px; padding: 4px 0; z-index: 100;
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,0.2); min-width: 160px;
|
||||||
|
}
|
||||||
|
.editor-export-menu.open { display: block; }
|
||||||
|
.editor-export-item {
|
||||||
|
display: block; width: 100%; padding: 6px 12px; font-size: 13px;
|
||||||
|
background: none; border: none; text-align: left; cursor: pointer;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.editor-export-item:hover { background: var(--hover); }
|
||||||
|
|
||||||
/* ── File Tree (sidebar-content region) ──── */
|
/* ── File Tree (sidebar-content region) ──── */
|
||||||
|
|
||||||
.editor-file-tree {
|
.editor-file-tree {
|
||||||
flex: 1; overflow-y: auto; overflow-x: hidden;
|
flex: 1; overflow-y: auto; overflow-x: hidden;
|
||||||
padding: 4px 0; font-size: 13px;
|
font-size: 13px; user-select: none;
|
||||||
user-select: none;
|
display: flex; flex-direction: column;
|
||||||
}
|
}
|
||||||
|
.editor-tree-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 6px 8px; flex-shrink: 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.editor-tree-title { font-size: 11px; font-weight: 600; text-transform: uppercase; color: var(--text-tertiary); letter-spacing: 0.5px; }
|
||||||
|
.editor-tree-add {
|
||||||
|
background: none; border: none; color: var(--text-tertiary);
|
||||||
|
cursor: pointer; padding: 2px; border-radius: 4px;
|
||||||
|
display: flex; align-items: center;
|
||||||
|
}
|
||||||
|
.editor-tree-add:hover { background: var(--hover); color: var(--accent); }
|
||||||
|
.editor-tree-content { flex: 1; overflow-y: auto; padding: 4px 0; }
|
||||||
.editor-tree-row {
|
.editor-tree-row {
|
||||||
display: flex; align-items: center; gap: 4px;
|
display: flex; align-items: center; gap: 4px;
|
||||||
padding: 3px 8px; cursor: pointer;
|
padding: 3px 8px; cursor: pointer;
|
||||||
@@ -43,6 +77,21 @@
|
|||||||
.editor-tree-icon { flex-shrink: 0; font-size: 14px; line-height: 1; }
|
.editor-tree-icon { flex-shrink: 0; font-size: 14px; line-height: 1; }
|
||||||
.editor-tree-name { overflow: hidden; text-overflow: ellipsis; }
|
.editor-tree-name { overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
|
||||||
|
/* Git status indicators (v0.21.6) */
|
||||||
|
.editor-tree-row.git-modified .editor-tree-name { color: var(--warning, #e5a50a); }
|
||||||
|
.editor-tree-row.git-added .editor-tree-name { color: var(--success, #22c55e); }
|
||||||
|
.editor-tree-row.git-untracked .editor-tree-name { color: var(--text-tertiary); font-style: italic; }
|
||||||
|
.editor-tree-row.git-deleted .editor-tree-name { color: var(--error, #ef4444); text-decoration: line-through; }
|
||||||
|
.editor-tree-row.git-modified::after,
|
||||||
|
.editor-tree-row.git-added::after,
|
||||||
|
.editor-tree-row.git-untracked::after {
|
||||||
|
font-size: 10px; margin-left: auto; padding-right: 4px; flex-shrink: 0;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
.editor-tree-row.git-modified::after { content: 'M'; color: var(--warning, #e5a50a); }
|
||||||
|
.editor-tree-row.git-added::after { content: 'A'; color: var(--success, #22c55e); }
|
||||||
|
.editor-tree-row.git-untracked::after { content: 'U'; }
|
||||||
|
|
||||||
.editor-tree-loading, .editor-tree-empty, .editor-tree-error {
|
.editor-tree-loading, .editor-tree-empty, .editor-tree-error {
|
||||||
padding: 16px; text-align: center; color: var(--text-tertiary); font-size: 12px;
|
padding: 16px; text-align: center; color: var(--text-tertiary); font-size: 12px;
|
||||||
}
|
}
|
||||||
@@ -156,9 +205,15 @@
|
|||||||
}
|
}
|
||||||
/* Inherit chat message styles when embedded */
|
/* Inherit chat message styles when embedded */
|
||||||
.editor-chat-messages .messages { height: auto; overflow: visible; }
|
.editor-chat-messages .messages { height: auto; overflow: visible; }
|
||||||
.editor-chat-input { flex-shrink: 0; }
|
.editor-chat-input { flex-shrink: 0; max-height: 140px; overflow: hidden; }
|
||||||
/* Inherit input-area styles */
|
/* Inherit input-area styles — constrain for narrow pane */
|
||||||
.editor-chat-input .input-area { border-top: 1px solid var(--border); }
|
.editor-chat-input .input-area { border-top: 1px solid var(--border); padding: 0 8px 8px; }
|
||||||
|
.editor-chat-input .input-wrap { max-width: none; }
|
||||||
|
.editor-chat-input .input-wrap textarea,
|
||||||
|
.editor-chat-input #messageInputWrap .cm-editor { max-height: 80px; padding: 8px; font-size: 13px; }
|
||||||
|
.editor-chat-input .input-meta { min-height: 0; padding: 2px 4px 0; }
|
||||||
|
.editor-chat-input .input-toolbar { gap: 2px; }
|
||||||
|
.editor-chat-input .input-toolbar button { padding: 4px; }
|
||||||
|
|
||||||
/* ── Status Bar (footer) ─────────────────── */
|
/* ── Status Bar (footer) ─────────────────── */
|
||||||
|
|
||||||
@@ -171,9 +226,12 @@
|
|||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
}
|
}
|
||||||
.editor-status-sep { opacity: 0.3; }
|
.editor-status-sep { opacity: 0.3; }
|
||||||
.editor-status-right { margin-left: auto; }
|
.editor-status-right { margin-left: auto; display: flex; gap: 8px; }
|
||||||
.editor-status-branch { font-size: 11px; }
|
.editor-status-branch { font-size: 11px; }
|
||||||
.editor-status-file { max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.editor-status-file { max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.editor-status-save { font-size: 11px; }
|
||||||
|
.editor-status-save.unsaved { color: var(--warning, #e5a50a); }
|
||||||
|
.editor-status-words { font-size: 11px; }
|
||||||
|
|
||||||
/* ── Context Menu ────────────────────────── */
|
/* ── Context Menu ────────────────────────── */
|
||||||
|
|
||||||
|
|||||||
@@ -916,6 +916,7 @@ a:hover { text-decoration: underline; }
|
|||||||
/* ── Input Area ──────────────────────────── */
|
/* ── Input Area ──────────────────────────── */
|
||||||
|
|
||||||
.input-area { padding: 0 1rem 1rem; flex-shrink: 0; }
|
.input-area { padding: 0 1rem 1rem; flex-shrink: 0; }
|
||||||
|
.input-area:empty { padding: 0; min-height: 0; }
|
||||||
|
|
||||||
/* Token counter below input */
|
/* Token counter below input */
|
||||||
.input-meta { display: flex; justify-content: space-between; align-items: center; padding: 2px 12px 0; min-height: 18px; }
|
.input-meta { display: flex; justify-content: space-between; align-items: center; padding: 2px 12px 0; min-height: 18px; }
|
||||||
@@ -1762,15 +1763,39 @@ select option { background: var(--bg-surface); color: var(--text); }
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.mode-btn {
|
.mode-btn {
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; gap: 6px;
|
||||||
width: 32px; height: 32px; border: none;
|
padding: 4px 10px; height: 28px; border: none;
|
||||||
border-radius: var(--radius); background: none;
|
border-radius: var(--radius); background: none;
|
||||||
color: var(--text-3); cursor: pointer;
|
color: var(--text-3); cursor: pointer; font-size: 12px;
|
||||||
transition: background var(--transition), color var(--transition);
|
transition: background var(--transition), color var(--transition);
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
.mode-btn svg { flex-shrink: 0; }
|
||||||
|
.mode-btn-label { font-weight: 500; }
|
||||||
.mode-btn:hover { background: var(--bg-hover); color: var(--text); }
|
.mode-btn:hover { background: var(--bg-hover); color: var(--text); }
|
||||||
.mode-btn.active { background: var(--bg-active); color: var(--accent); }
|
.mode-btn.active { background: var(--bg-active); color: var(--accent); }
|
||||||
.sidebar.collapsed .mode-selector { justify-content: center; }
|
.sidebar.collapsed .mode-selector { justify-content: center; }
|
||||||
|
.sidebar.collapsed .mode-btn-label { display: none; }
|
||||||
|
.sidebar.collapsed .mode-btn { padding: 4px; width: 28px; justify-content: center; }
|
||||||
|
|
||||||
|
/* ── Sidebar Tabs (v0.21.6) ──────────── */
|
||||||
|
|
||||||
|
.sidebar-tabs {
|
||||||
|
display: flex; padding: 0 8px; gap: 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.sidebar-tab {
|
||||||
|
flex: 1; padding: 6px 0; font-size: 12px; font-weight: 500;
|
||||||
|
background: none; border: none; border-bottom: 2px solid transparent;
|
||||||
|
color: var(--text-3); cursor: pointer;
|
||||||
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.sidebar-tab:hover { color: var(--text-2); }
|
||||||
|
.sidebar-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||||
|
.sidebar.collapsed .sidebar-tabs { display: none; }
|
||||||
|
.sidebar-tab-panel { flex: 1; overflow-y: auto; min-height: 0; display: flex; flex-direction: column; }
|
||||||
|
.sidebar-files-panel { padding: 0; }
|
||||||
|
|
||||||
/* ── REPL Console (v0.21.3) ────────────── */
|
/* ── REPL Console (v0.21.3) ────────────── */
|
||||||
|
|
||||||
@@ -2599,3 +2624,41 @@ select option { background: var(--bg-surface); color: var(--text); }
|
|||||||
.project-show-archived:hover { color: var(--text-2); }
|
.project-show-archived:hover { color: var(--text-2); }
|
||||||
.project-group.archived { opacity: 0.5; }
|
.project-group.archived { opacity: 0.5; }
|
||||||
.project-group.archived .project-header { font-style: italic; }
|
.project-group.archived .project-header { font-style: italic; }
|
||||||
|
|
||||||
|
/* ── Router Workspace Picker (v0.21.6) ──── */
|
||||||
|
|
||||||
|
.router-picker-overlay {
|
||||||
|
position: fixed; inset: 0; z-index: 9999;
|
||||||
|
background: rgba(0,0,0,0.5); backdrop-filter: blur(2px);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.router-picker {
|
||||||
|
background: var(--bg-primary, #1a1a2e); color: var(--text, #e0e0e0);
|
||||||
|
border: 1px solid var(--border, #333); border-radius: 12px;
|
||||||
|
padding: 24px; min-width: 320px; max-width: 440px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
.router-picker h3 { margin: 0 0 4px; font-size: 16px; font-weight: 600; }
|
||||||
|
.router-picker p { margin: 0 0 16px; font-size: 13px; color: var(--text-2, #999); }
|
||||||
|
.router-picker-list {
|
||||||
|
display: flex; flex-direction: column; gap: 4px;
|
||||||
|
max-height: 280px; overflow-y: auto; margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.router-picker-empty {
|
||||||
|
padding: 24px; text-align: center; color: var(--text-3, #666); font-size: 13px;
|
||||||
|
}
|
||||||
|
.router-picker-item {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 10px 12px; border: 1px solid var(--border, #333);
|
||||||
|
border-radius: 8px; background: none; color: var(--text, #e0e0e0);
|
||||||
|
cursor: pointer; font-size: 14px; text-align: left;
|
||||||
|
transition: background 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.router-picker-item:hover {
|
||||||
|
background: var(--hover, rgba(255,255,255,0.05));
|
||||||
|
border-color: var(--accent, #6366f1);
|
||||||
|
}
|
||||||
|
.router-picker-icon { font-size: 18px; flex-shrink: 0; }
|
||||||
|
.router-picker-actions {
|
||||||
|
display: flex; gap: 8px; justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
<link rel="stylesheet" href="css/styles.css?v=%%APP_VERSION%%">
|
<link rel="stylesheet" href="css/styles.css?v=%%APP_VERSION%%">
|
||||||
<link rel="stylesheet" href="css/editor-mode.css?v=%%APP_VERSION%%">
|
<link rel="stylesheet" href="css/editor-mode.css?v=%%APP_VERSION%%">
|
||||||
|
<!-- article-mode.css removed — merged into editor-mode (v0.21.6) -->
|
||||||
<link rel="stylesheet" href="css/persona-kb.css?v=%%APP_VERSION%%">
|
<link rel="stylesheet" href="css/persona-kb.css?v=%%APP_VERSION%%">
|
||||||
<link rel="stylesheet" href="css/memory.css?v=%%APP_VERSION%%">
|
<link rel="stylesheet" href="css/memory.css?v=%%APP_VERSION%%">
|
||||||
<link rel="stylesheet" href="css/notifications.css?v=%%APP_VERSION%%">
|
<link rel="stylesheet" href="css/notifications.css?v=%%APP_VERSION%%">
|
||||||
@@ -76,7 +77,13 @@
|
|||||||
<!-- Mode Selector (shown when ≥1 extension surface registered) -->
|
<!-- Mode Selector (shown when ≥1 extension surface registered) -->
|
||||||
<div class="mode-selector" id="modeSelectorWrap" style="display:none"></div>
|
<div class="mode-selector" id="modeSelectorWrap" style="display:none"></div>
|
||||||
|
|
||||||
<div data-surface-region="sidebar-content">
|
<!-- Sidebar Tabs: Chats / Files (v0.21.6) -->
|
||||||
|
<div class="sidebar-tabs" id="sidebarTabs">
|
||||||
|
<button class="sidebar-tab active" data-tab="chats">Chats</button>
|
||||||
|
<button class="sidebar-tab" data-tab="files" id="sidebarFilesTab" style="display:none">Files</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div data-surface-region="sidebar-content" class="sidebar-tab-panel" data-tab-panel="chats">
|
||||||
<div class="sidebar-search" id="sidebarSearch">
|
<div class="sidebar-search" id="sidebarSearch">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||||
<input type="text" id="chatSearchInput" placeholder="Search chats…" autocomplete="off">
|
<input type="text" id="chatSearchInput" placeholder="Search chats…" autocomplete="off">
|
||||||
@@ -85,6 +92,9 @@
|
|||||||
<div class="sidebar-chats" id="chatHistory"></div>
|
<div class="sidebar-chats" id="chatHistory"></div>
|
||||||
</div><!-- /sidebar-content region -->
|
</div><!-- /sidebar-content region -->
|
||||||
|
|
||||||
|
<!-- Files panel (populated by EditorMode, hidden until Files tab active) -->
|
||||||
|
<div class="sidebar-tab-panel sidebar-files-panel" data-tab-panel="files" style="display:none" id="sidebarFilesPanel"></div>
|
||||||
|
|
||||||
<!-- Notes shortcut -->
|
<!-- Notes shortcut -->
|
||||||
<div class="sidebar-notes-btn">
|
<div class="sidebar-notes-btn">
|
||||||
<button class="sb-notes" id="notesBtn" title="Notes">
|
<button class="sb-notes" id="notesBtn" title="Notes">
|
||||||
@@ -1203,6 +1213,7 @@
|
|||||||
<script src="js/settings-handlers.js?v=%%APP_VERSION%%"></script>
|
<script src="js/settings-handlers.js?v=%%APP_VERSION%%"></script>
|
||||||
<script src="js/admin-handlers.js?v=%%APP_VERSION%%"></script>
|
<script src="js/admin-handlers.js?v=%%APP_VERSION%%"></script>
|
||||||
<script src="js/editor-mode.js?v=%%APP_VERSION%%"></script>
|
<script src="js/editor-mode.js?v=%%APP_VERSION%%"></script>
|
||||||
|
<script src="js/router.js?v=%%APP_VERSION%%"></script>
|
||||||
<script src="js/app.js?v=%%APP_VERSION%%"></script>
|
<script src="js/app.js?v=%%APP_VERSION%%"></script>
|
||||||
<script>
|
<script>
|
||||||
// ── Service Worker Registration ─────────
|
// ── Service Worker Registration ─────────
|
||||||
|
|||||||
@@ -230,8 +230,24 @@ const API = {
|
|||||||
}
|
}
|
||||||
return resp.text();
|
return resp.text();
|
||||||
},
|
},
|
||||||
writeWorkspaceFile(wsId, path, content) {
|
async writeWorkspaceFile(wsId, path, content) {
|
||||||
return this._put(`/api/v1/workspaces/${wsId}/files/write`, { path, content });
|
// Backend reads path from query string and content from raw request body
|
||||||
|
const url = BASE + `/api/v1/workspaces/${wsId}/files/write?path=${encodeURIComponent(path)}`;
|
||||||
|
const resp = await fetch(url, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/plain',
|
||||||
|
'Authorization': `Bearer ${this.accessToken}`,
|
||||||
|
},
|
||||||
|
body: content,
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const data = await resp.json().catch(() => ({}));
|
||||||
|
const err = new Error(data.error || `HTTP ${resp.status}`);
|
||||||
|
err.status = resp.status;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return resp.json();
|
||||||
},
|
},
|
||||||
deleteWorkspaceFile(wsId, path) {
|
deleteWorkspaceFile(wsId, path) {
|
||||||
return this._del(`/api/v1/workspaces/${wsId}/files/delete?path=${encodeURIComponent(path)}`);
|
return this._del(`/api/v1/workspaces/${wsId}/files/delete?path=${encodeURIComponent(path)}`);
|
||||||
|
|||||||
@@ -211,13 +211,25 @@ async function startApp() {
|
|||||||
UI.renderChatList();
|
UI.renderChatList();
|
||||||
UI.updateModelSelector();
|
UI.updateModelSelector();
|
||||||
|
|
||||||
// Restore last-active chat from sessionStorage (QOL: state restore on refresh)
|
// Check for workspaces on startup — register editor surface early (v0.21.6)
|
||||||
try {
|
// This ensures the Editor button + Files tab appear without needing to browse first.
|
||||||
const savedChat = sessionStorage.getItem('cs-active-chat');
|
if (typeof EditorMode !== 'undefined') {
|
||||||
if (savedChat && App.chats.some(c => c.id === savedChat)) {
|
try { await EditorMode.checkStartup(); } catch (_) {}
|
||||||
selectChat(savedChat);
|
}
|
||||||
}
|
|
||||||
} catch (_) {}
|
// Initialize hash router (v0.21.6) — replaces sessionStorage chat restore.
|
||||||
|
// Reads current URL hash and routes to the right surface/chat/workspace.
|
||||||
|
if (typeof Router !== 'undefined') {
|
||||||
|
Router.init();
|
||||||
|
} else {
|
||||||
|
// Fallback: restore last-active chat from sessionStorage
|
||||||
|
try {
|
||||||
|
const savedChat = sessionStorage.getItem('cs-active-chat');
|
||||||
|
if (savedChat && App.chats.some(c => c.id === savedChat)) {
|
||||||
|
selectChat(savedChat);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
UI.updateUser();
|
UI.updateUser();
|
||||||
UI.showAdminButton(API.isAdmin);
|
UI.showAdminButton(API.isAdmin);
|
||||||
@@ -443,6 +455,37 @@ function initListeners() {
|
|||||||
_initPanelSwipe(); // from panels.js — mobile swipe navigation
|
_initPanelSwipe(); // from panels.js — mobile swipe navigation
|
||||||
_initPanelResponsive(); // from panels.js — auto-collapse dual on resize
|
_initPanelResponsive(); // from panels.js — auto-collapse dual on resize
|
||||||
_initPanelOverlay(); // from panels.js — mobile tap-to-close overlay
|
_initPanelOverlay(); // from panels.js — mobile tap-to-close overlay
|
||||||
|
_initSidebarTabs(); // v0.21.6: Chats/Files tab switching
|
||||||
|
}
|
||||||
|
|
||||||
|
function _initSidebarTabs() {
|
||||||
|
const tabBar = document.getElementById('sidebarTabs');
|
||||||
|
if (!tabBar) return;
|
||||||
|
|
||||||
|
tabBar.addEventListener('click', (e) => {
|
||||||
|
const btn = e.target.closest('.sidebar-tab');
|
||||||
|
if (!btn) return;
|
||||||
|
const tabName = btn.dataset.tab;
|
||||||
|
|
||||||
|
// Update active tab
|
||||||
|
tabBar.querySelectorAll('.sidebar-tab').forEach(t => t.classList.toggle('active', t === btn));
|
||||||
|
|
||||||
|
// Show/hide panels
|
||||||
|
document.querySelectorAll('.sidebar-tab-panel').forEach(p => {
|
||||||
|
p.style.display = p.dataset.tabPanel === tabName ? '' : 'none';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Show or hide the Files tab. Called by EditorMode on register/unregister. */
|
||||||
|
function showSidebarFilesTab(show) {
|
||||||
|
const tab = document.getElementById('sidebarFilesTab');
|
||||||
|
if (tab) tab.style.display = show ? '' : 'none';
|
||||||
|
// If hiding and Files was active, switch back to Chats
|
||||||
|
if (!show && tab?.classList.contains('active')) {
|
||||||
|
const chatsTab = document.querySelector('.sidebar-tab[data-tab="chats"]');
|
||||||
|
if (chatsTab) chatsTab.click();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function _initGlobalKeyboard() {
|
function _initGlobalKeyboard() {
|
||||||
|
|||||||
@@ -193,6 +193,7 @@ async function loadChats() {
|
|||||||
model: c.model || '',
|
model: c.model || '',
|
||||||
messageCount: c.message_count || 0,
|
messageCount: c.message_count || 0,
|
||||||
projectId: c.project_id || null,
|
projectId: c.project_id || null,
|
||||||
|
workspace_id: c.workspace_id || null,
|
||||||
messages: [],
|
messages: [],
|
||||||
updatedAt: c.updated_at,
|
updatedAt: c.updated_at,
|
||||||
settings: c.settings || {},
|
settings: c.settings || {},
|
||||||
@@ -257,6 +258,14 @@ async function selectChat(chatId) {
|
|||||||
// Refresh KB toggle state for the new channel
|
// Refresh KB toggle state for the new channel
|
||||||
if (typeof KnowledgeUI !== 'undefined') KnowledgeUI.onChatChanged();
|
if (typeof KnowledgeUI !== 'undefined') KnowledgeUI.onChatChanged();
|
||||||
updateChatTokenCount();
|
updateChatTokenCount();
|
||||||
|
|
||||||
|
// Notify surfaces and extensions about channel switch (v0.21.6)
|
||||||
|
Events.emit('chat.switched', { chatId, projectId: chat.projectId || null }, { localOnly: true });
|
||||||
|
|
||||||
|
// Sync URL hash (v0.21.6)
|
||||||
|
if (typeof Router !== 'undefined' && Router._initialized) {
|
||||||
|
Router.update('chat', { chatId });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Chat Header Token Count ──────────────────
|
// ── Chat Header Token Count ──────────────────
|
||||||
@@ -386,6 +395,12 @@ async function newChat() {
|
|||||||
if (ov) ov.style.display = 'none';
|
if (ov) ov.style.display = 'none';
|
||||||
}
|
}
|
||||||
if (typeof KnowledgeUI !== 'undefined') KnowledgeUI.onChatChanged();
|
if (typeof KnowledgeUI !== 'undefined') KnowledgeUI.onChatChanged();
|
||||||
|
// Notify surfaces — no channel selected (v0.21.6)
|
||||||
|
Events.emit('chat.switched', { chatId: null, projectId: null }, { localOnly: true });
|
||||||
|
// Sync URL hash
|
||||||
|
if (typeof Router !== 'undefined' && Router._initialized) {
|
||||||
|
Router.update('chat');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteChat(chatId) {
|
async function deleteChat(chatId) {
|
||||||
@@ -511,7 +526,7 @@ async function sendMessage() {
|
|||||||
try {
|
try {
|
||||||
const title = text.slice(0, 50) + (text.length > 50 ? '...' : '');
|
const title = text.slice(0, 50) + (text.length > 50 ? '...' : '');
|
||||||
const resp = await API.createChannel(title, model, App.settings.systemPrompt, 'direct');
|
const resp = await API.createChannel(title, model, App.settings.systemPrompt, 'direct');
|
||||||
const chat = { id: resp.id, title: resp.title, type: 'direct', model, messages: [], messageCount: 0, projectId: resp.project_id || null, updatedAt: resp.updated_at, settings: resp.settings || {} };
|
const chat = { id: resp.id, title: resp.title, type: 'direct', model, messages: [], messageCount: 0, projectId: resp.project_id || null, workspace_id: resp.workspace_id || null, updatedAt: resp.updated_at, settings: resp.settings || {} };
|
||||||
// Auto-assign to active project (v0.19.1)
|
// Auto-assign to active project (v0.19.1)
|
||||||
if (App.activeProjectId && !chat.projectId) {
|
if (App.activeProjectId && !chat.projectId) {
|
||||||
try {
|
try {
|
||||||
@@ -522,6 +537,12 @@ async function sendMessage() {
|
|||||||
App.chats.unshift(chat);
|
App.chats.unshift(chat);
|
||||||
App.currentChatId = chat.id;
|
App.currentChatId = chat.id;
|
||||||
UI.renderChatList();
|
UI.renderChatList();
|
||||||
|
// Notify surfaces about new chat creation (v0.21.6)
|
||||||
|
Events.emit('chat.created', { chatId: chat.id, projectId: chat.projectId || null }, { localOnly: true });
|
||||||
|
// Sync URL hash
|
||||||
|
if (typeof Router !== 'undefined' && Router._initialized) {
|
||||||
|
Router.update('chat', { chatId: chat.id });
|
||||||
|
}
|
||||||
} catch (e) { UI.toast('Failed to create chat: ' + e.message, 'error'); return; }
|
} catch (e) { UI.toast('Failed to create chat: ' + e.message, 'error'); return; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const EditorMode = {
|
|||||||
// File tree
|
// File tree
|
||||||
_treeData: [], // flat file list from API
|
_treeData: [], // flat file list from API
|
||||||
_expandedDirs: new Set(['']), // expanded directory paths
|
_expandedDirs: new Set(['']), // expanded directory paths
|
||||||
|
_gitStatusMap: new Map(), // path → git status ('M','A','?','D')
|
||||||
|
|
||||||
// Tabs + editors
|
// Tabs + editors
|
||||||
_openFiles: new Map(), // path → { tab, editorWrap, editor(CM6), content, modified, language }
|
_openFiles: new Map(), // path → { tab, editorWrap, editor(CM6), content, modified, language }
|
||||||
@@ -34,6 +35,7 @@ const EditorMode = {
|
|||||||
|
|
||||||
// Split
|
// Split
|
||||||
_splitRatio: 0.6, // editor pane width ratio (0.0-1.0)
|
_splitRatio: 0.6, // editor pane width ratio (0.0-1.0)
|
||||||
|
_chatVisible: true, // right pane (AI chat) visible?
|
||||||
|
|
||||||
// ── Initialization ───────────────────────
|
// ── Initialization ───────────────────────
|
||||||
|
|
||||||
@@ -44,18 +46,31 @@ const EditorMode = {
|
|||||||
async check() {
|
async check() {
|
||||||
const channelId = App.currentChatId;
|
const channelId = App.currentChatId;
|
||||||
if (!channelId) {
|
if (!channelId) {
|
||||||
this._unregister();
|
// Don't unregister on empty state — startup check may have registered
|
||||||
|
if (!this._registered) return;
|
||||||
|
// Only unregister if we were registered via a channel (not startup)
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const ch = await API.getChannel(channelId);
|
// Check local data first to avoid unnecessary API calls
|
||||||
const wsId = ch.workspace_id || null;
|
const localChat = App.chats?.find(c => c.id === channelId);
|
||||||
|
let wsId = localChat?.workspace_id || null;
|
||||||
|
let projectId = localChat?.projectId || null;
|
||||||
|
|
||||||
// Also check project workspace if channel doesn't have one
|
// If no local workspace, fetch from server (has workspace_id column)
|
||||||
if (!wsId && ch.project_id) {
|
if (!wsId) {
|
||||||
try {
|
try {
|
||||||
const proj = await API.getProject(ch.project_id);
|
const ch = await API.getChannel(channelId);
|
||||||
|
wsId = ch.workspace_id || null;
|
||||||
|
projectId = projectId || ch.project_id || null;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check project workspace if channel doesn't have one
|
||||||
|
if (!wsId && projectId) {
|
||||||
|
try {
|
||||||
|
const proj = await API.getProject(projectId);
|
||||||
if (proj.workspace_id) {
|
if (proj.workspace_id) {
|
||||||
this._register(proj.workspace_id, proj.name || 'Workspace');
|
this._register(proj.workspace_id, proj.name || 'Workspace');
|
||||||
return;
|
return;
|
||||||
@@ -64,21 +79,54 @@ const EditorMode = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (wsId) {
|
if (wsId) {
|
||||||
// Fetch workspace details
|
|
||||||
try {
|
try {
|
||||||
const ws = await API.getWorkspace(wsId);
|
const ws = await API.getWorkspace(wsId);
|
||||||
this._register(wsId, ws.name || 'Workspace');
|
this._register(wsId, ws.name || 'Workspace');
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
this._register(wsId, 'Workspace');
|
this._register(wsId, 'Workspace');
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
this._unregister();
|
|
||||||
}
|
}
|
||||||
|
// Don't unregister just because this channel has no workspace
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[EditorMode] check failed:', e);
|
console.warn('[EditorMode] check failed:', e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Startup check — register editor if any workspace exists.
|
||||||
|
* Called once from startApp(), independent of channel selection.
|
||||||
|
*/
|
||||||
|
async checkStartup() {
|
||||||
|
if (this._registered) return;
|
||||||
|
try {
|
||||||
|
// First: check active project for workspace
|
||||||
|
if (App.activeProjectId) {
|
||||||
|
const proj = App.projects?.find(p => p.id === App.activeProjectId);
|
||||||
|
if (proj?.workspace_id) {
|
||||||
|
this._register(proj.workspace_id, proj.name || 'Workspace');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then: check all projects for workspaces
|
||||||
|
for (const p of (App.projects || [])) {
|
||||||
|
if (p.workspace_id) {
|
||||||
|
this._register(p.workspace_id, p.name || 'Workspace');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finally: check if any workspaces exist at all
|
||||||
|
const resp = await API.listWorkspaces();
|
||||||
|
const workspaces = Array.isArray(resp) ? resp : (resp?.data || []);
|
||||||
|
if (workspaces.length > 0) {
|
||||||
|
this._register(workspaces[0].id, workspaces[0].name || 'Workspace');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[EditorMode] startup check failed:', e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
_register(wsId, name) {
|
_register(wsId, name) {
|
||||||
this._wsId = wsId;
|
this._wsId = wsId;
|
||||||
this._wsName = name;
|
this._wsName = name;
|
||||||
@@ -86,17 +134,43 @@ const EditorMode = {
|
|||||||
if (this._registered) return;
|
if (this._registered) return;
|
||||||
this._registered = true;
|
this._registered = true;
|
||||||
|
|
||||||
|
// Build DOM early so file tree is available for sidebar Files tab
|
||||||
|
if (!this._built) this._build();
|
||||||
|
|
||||||
Surfaces.register('editor', {
|
Surfaces.register('editor', {
|
||||||
label: 'Editor',
|
label: 'Editor',
|
||||||
icon: 'code',
|
icon: 'code',
|
||||||
regions: ['surface-header', 'surface-main', 'surface-footer', 'sidebar-content'],
|
regions: ['surface-header', 'surface-main'],
|
||||||
activate: () => this._activate(),
|
activate: () => this._activate(),
|
||||||
deactivate: () => this._deactivate(),
|
deactivate: () => this._deactivate(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Show the Files tab in sidebar and populate it
|
||||||
|
if (typeof showSidebarFilesTab === 'function') showSidebarFilesTab(true);
|
||||||
|
const filesPanel = document.getElementById('sidebarFilesPanel');
|
||||||
|
if (filesPanel && this._els?.fileTree) {
|
||||||
|
filesPanel.innerHTML = '';
|
||||||
|
filesPanel.appendChild(this._els.fileTree);
|
||||||
|
}
|
||||||
|
this._refreshFileTree();
|
||||||
|
|
||||||
console.log(`[EditorMode] Registered for workspace ${wsId}`);
|
console.log(`[EditorMode] Registered for workspace ${wsId}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Direct open — bypass check(), register with a specific workspace.
|
||||||
|
* Used by Router for hash-based navigation (e.g. #/editor/ws_abc123).
|
||||||
|
*/
|
||||||
|
openDirect(wsId) {
|
||||||
|
if (this._wsId === wsId && this._registered) return;
|
||||||
|
if (this._registered && this._wsId !== wsId) this._unregister();
|
||||||
|
this._register(wsId, 'Workspace');
|
||||||
|
// Async: fetch real name
|
||||||
|
API.getWorkspace(wsId).then(ws => {
|
||||||
|
this._wsName = ws.name || 'Workspace';
|
||||||
|
}).catch(() => {});
|
||||||
|
},
|
||||||
|
|
||||||
_unregister() {
|
_unregister() {
|
||||||
if (!this._registered) return;
|
if (!this._registered) return;
|
||||||
if (this._active) {
|
if (this._active) {
|
||||||
@@ -106,9 +180,15 @@ const EditorMode = {
|
|||||||
this._registered = false;
|
this._registered = false;
|
||||||
this._wsId = null;
|
this._wsId = null;
|
||||||
this._built = false;
|
this._built = false;
|
||||||
this._els = null;
|
|
||||||
this._openFiles.clear();
|
this._openFiles.clear();
|
||||||
this._activeFile = null;
|
this._activeFile = null;
|
||||||
|
|
||||||
|
// Clean up sidebar Files tab
|
||||||
|
if (typeof showSidebarFilesTab === 'function') showSidebarFilesTab(false);
|
||||||
|
const filesPanel = document.getElementById('sidebarFilesPanel');
|
||||||
|
if (filesPanel) filesPanel.innerHTML = '';
|
||||||
|
|
||||||
|
this._els = null;
|
||||||
console.log('[EditorMode] Unregistered');
|
console.log('[EditorMode] Unregistered');
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -128,21 +208,21 @@ const EditorMode = {
|
|||||||
const headerEl = regions.get('surface-header');
|
const headerEl = regions.get('surface-header');
|
||||||
if (headerEl) headerEl.appendChild(this._els.header);
|
if (headerEl) headerEl.appendChild(this._els.header);
|
||||||
|
|
||||||
// Sidebar → file tree
|
// Main → split pane (editor + chat). Footer lives inside left pane.
|
||||||
const sidebarEl = regions.get('sidebar-content');
|
|
||||||
if (sidebarEl) sidebarEl.appendChild(this._els.fileTree);
|
|
||||||
|
|
||||||
// Main → split pane (editor + chat)
|
|
||||||
const mainEl = regions.get('surface-main');
|
const mainEl = regions.get('surface-main');
|
||||||
if (mainEl) mainEl.appendChild(this._els.main);
|
if (mainEl) mainEl.appendChild(this._els.main);
|
||||||
|
|
||||||
// Footer → status bar
|
// Hide the global footer region — editor has its own status bar in the left pane
|
||||||
const footerEl = regions.get('surface-footer');
|
const footerRegion = regions.get('surface-footer');
|
||||||
if (footerEl) footerEl.appendChild(this._els.footer);
|
if (footerRegion) footerRegion.style.display = 'none';
|
||||||
|
|
||||||
// Embed saved chat DOM into our chat pane
|
// Embed saved chat DOM into our chat pane
|
||||||
this._embedChat();
|
this._embedChat();
|
||||||
|
|
||||||
|
// Auto-switch sidebar to Files tab
|
||||||
|
const filesTab = document.getElementById('sidebarFilesTab');
|
||||||
|
if (filesTab && !filesTab.classList.contains('active')) filesTab.click();
|
||||||
|
|
||||||
// Refresh file tree
|
// Refresh file tree
|
||||||
this._refreshFileTree();
|
this._refreshFileTree();
|
||||||
this._refreshGitBranch();
|
this._refreshGitBranch();
|
||||||
@@ -157,6 +237,19 @@ const EditorMode = {
|
|||||||
_deactivate() {
|
_deactivate() {
|
||||||
this._active = false;
|
this._active = false;
|
||||||
|
|
||||||
|
// Auto-save all modified files before switching surfaces (v0.21.6)
|
||||||
|
for (const [path, f] of this._openFiles) {
|
||||||
|
if (f.modified) this._saveFile(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switch sidebar back to Chats tab
|
||||||
|
const chatsTab = document.querySelector('.sidebar-tab[data-tab="chats"]');
|
||||||
|
if (chatsTab && !chatsTab.classList.contains('active')) chatsTab.click();
|
||||||
|
|
||||||
|
// Restore global footer region visibility
|
||||||
|
const footerRegion = Surfaces._regionEls?.get('surface-footer');
|
||||||
|
if (footerRegion) footerRegion.style.display = '';
|
||||||
|
|
||||||
// Return chat DOM to Surfaces saved store before regions are saved
|
// Return chat DOM to Surfaces saved store before regions are saved
|
||||||
this._returnChat();
|
this._returnChat();
|
||||||
},
|
},
|
||||||
@@ -209,9 +302,9 @@ const EditorMode = {
|
|||||||
this._els = {
|
this._els = {
|
||||||
header: this._buildHeader(),
|
header: this._buildHeader(),
|
||||||
fileTree: this._buildFileTreeContainer(),
|
fileTree: this._buildFileTreeContainer(),
|
||||||
main: this._buildMain(),
|
footer: null, // built inside _buildMain and attached to left pane
|
||||||
footer: this._buildFooter(),
|
|
||||||
};
|
};
|
||||||
|
this._els.main = this._buildMain(); // builds and attaches footer
|
||||||
this._built = true;
|
this._built = true;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -224,18 +317,78 @@ const EditorMode = {
|
|||||||
<span class="editor-branch" id="editorBranch"></span>
|
<span class="editor-branch" id="editorBranch"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="editor-header-right">
|
<div class="editor-header-right">
|
||||||
|
<button class="editor-hdr-btn" id="editorNewFileBtn" title="New file">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="12" y1="18" x2="12" y2="12"/><line x1="9" y1="15" x2="15" y2="15"/></svg>
|
||||||
|
<span class="editor-hdr-label">New</span>
|
||||||
|
</button>
|
||||||
|
<div class="editor-export-wrap">
|
||||||
|
<button class="editor-hdr-btn" id="editorExportBtn" title="Export active file">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||||
|
<span class="editor-hdr-label">Export</span>
|
||||||
|
</button>
|
||||||
|
<div class="editor-export-menu" id="editorExportMenu">
|
||||||
|
<button class="editor-export-item" data-format="md">Download file</button>
|
||||||
|
<button class="editor-export-item" data-format="html">Export as HTML</button>
|
||||||
|
<button class="editor-export-item" data-format="clipboard">Copy to clipboard</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<button class="editor-hdr-btn" id="editorRefreshBtn" title="Refresh file tree">
|
<button class="editor-hdr-btn" id="editorRefreshBtn" title="Refresh file tree">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
<span class="editor-hdr-sep"></span>
|
||||||
|
<button class="editor-hdr-btn editor-chat-toggle active" id="editorChatToggle" title="Toggle AI chat panel">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||||
|
</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
|
// New file
|
||||||
|
el.querySelector('#editorNewFileBtn').addEventListener('click', () => this._createNewFile());
|
||||||
|
|
||||||
|
// Export dropdown
|
||||||
|
const exportBtn = el.querySelector('#editorExportBtn');
|
||||||
|
const exportMenu = el.querySelector('#editorExportMenu');
|
||||||
|
exportBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
exportMenu.classList.toggle('open');
|
||||||
|
});
|
||||||
|
exportMenu.querySelectorAll('.editor-export-item').forEach(item => {
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
exportMenu.classList.remove('open');
|
||||||
|
this._exportActiveFile(item.dataset.format);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.addEventListener('click', () => exportMenu.classList.remove('open'));
|
||||||
|
|
||||||
|
// Refresh
|
||||||
el.querySelector('#editorRefreshBtn').addEventListener('click', () => this._refreshFileTree());
|
el.querySelector('#editorRefreshBtn').addEventListener('click', () => this._refreshFileTree());
|
||||||
|
|
||||||
|
// Chat toggle
|
||||||
|
el.querySelector('#editorChatToggle').addEventListener('click', () => this._toggleChat());
|
||||||
|
|
||||||
return el;
|
return el;
|
||||||
},
|
},
|
||||||
|
|
||||||
_buildFileTreeContainer() {
|
_buildFileTreeContainer() {
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
el.className = 'editor-file-tree';
|
el.className = 'editor-file-tree';
|
||||||
el.innerHTML = '<div class="editor-tree-loading">Loading files…</div>';
|
|
||||||
|
// Tree header with label and new-file button
|
||||||
|
const hdr = document.createElement('div');
|
||||||
|
hdr.className = 'editor-tree-header';
|
||||||
|
hdr.innerHTML = `
|
||||||
|
<span class="editor-tree-title">Files</span>
|
||||||
|
<button class="editor-tree-add" title="New file">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||||
|
</button>`;
|
||||||
|
hdr.querySelector('.editor-tree-add').addEventListener('click', () => this._createNewFile());
|
||||||
|
el.appendChild(hdr);
|
||||||
|
|
||||||
|
// Tree content area (populated by _renderFileTree)
|
||||||
|
const treeContent = document.createElement('div');
|
||||||
|
treeContent.className = 'editor-tree-content';
|
||||||
|
treeContent.innerHTML = '<div class="editor-tree-loading">Loading files…</div>';
|
||||||
|
el.appendChild(treeContent);
|
||||||
|
|
||||||
return el;
|
return el;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -243,7 +396,7 @@ const EditorMode = {
|
|||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
el.className = 'editor-main';
|
el.className = 'editor-main';
|
||||||
|
|
||||||
// Left: editor area (tabs + code)
|
// Left: editor area (tabs + code + status bar)
|
||||||
const leftPane = document.createElement('div');
|
const leftPane = document.createElement('div');
|
||||||
leftPane.className = 'editor-left-pane';
|
leftPane.className = 'editor-left-pane';
|
||||||
|
|
||||||
@@ -262,16 +415,20 @@ const EditorMode = {
|
|||||||
<div class="editor-welcome-inner">
|
<div class="editor-welcome-inner">
|
||||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" opacity="0.3"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" opacity="0.3"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
|
||||||
<p>Select a file to start editing</p>
|
<p>Select a file to start editing</p>
|
||||||
<p class="editor-welcome-hint"><kbd>Ctrl+P</kbd> Quick Open</p>
|
<p class="editor-welcome-hint"><kbd>Ctrl+P</kbd> Quick Open or use <strong>+ New</strong> in the toolbar</p>
|
||||||
</div>`;
|
</div>`;
|
||||||
this._editorAreaEl.appendChild(this._welcomeEl);
|
this._editorAreaEl.appendChild(this._welcomeEl);
|
||||||
|
|
||||||
|
// Status bar — lives inside left pane so chat can go floor-to-ceiling
|
||||||
|
this._els.footer = this._buildFooter();
|
||||||
|
leftPane.appendChild(this._els.footer);
|
||||||
|
|
||||||
// Resize handle
|
// Resize handle
|
||||||
const handle = document.createElement('div');
|
const handle = document.createElement('div');
|
||||||
handle.className = 'editor-split-handle';
|
handle.className = 'editor-split-handle';
|
||||||
this._initResize(handle, leftPane);
|
this._initResize(handle, leftPane);
|
||||||
|
|
||||||
// Right: chat panel
|
// Right: chat panel (full height)
|
||||||
const rightPane = document.createElement('div');
|
const rightPane = document.createElement('div');
|
||||||
rightPane.className = 'editor-right-pane';
|
rightPane.className = 'editor-right-pane';
|
||||||
|
|
||||||
@@ -310,7 +467,10 @@ const EditorMode = {
|
|||||||
<span class="editor-status-cursor" id="editorStatusCursor"></span>
|
<span class="editor-status-cursor" id="editorStatusCursor"></span>
|
||||||
<span class="editor-status-sep">│</span>
|
<span class="editor-status-sep">│</span>
|
||||||
<span class="editor-status-lang" id="editorStatusLang"></span>
|
<span class="editor-status-lang" id="editorStatusLang"></span>
|
||||||
|
<span class="editor-status-sep editor-status-wc-sep" style="display:none">│</span>
|
||||||
|
<span class="editor-status-words" id="editorStatusWords" style="display:none"></span>
|
||||||
<span class="editor-status-right">
|
<span class="editor-status-right">
|
||||||
|
<span class="editor-status-save" id="editorStatusSave"></span>
|
||||||
<span class="editor-status-branch" id="editorStatusBranch"></span>
|
<span class="editor-status-branch" id="editorStatusBranch"></span>
|
||||||
</span>`;
|
</span>`;
|
||||||
return el;
|
return el;
|
||||||
@@ -366,12 +526,42 @@ const EditorMode = {
|
|||||||
this._renderFileTree();
|
this._renderFileTree();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[EditorMode] Failed to load file tree:', e);
|
console.error('[EditorMode] Failed to load file tree:', e);
|
||||||
this._els.fileTree.innerHTML = '<div class="editor-tree-error">Failed to load files</div>';
|
const content = this._els.fileTree.querySelector('.editor-tree-content') || this._els.fileTree;
|
||||||
|
content.innerHTML = '<div class="editor-tree-error">Failed to load files</div>';
|
||||||
|
}
|
||||||
|
// Refresh git status indicators (non-blocking)
|
||||||
|
this._refreshGitStatus();
|
||||||
|
},
|
||||||
|
|
||||||
|
async _refreshGitStatus() {
|
||||||
|
if (!this._wsId) return;
|
||||||
|
this._gitStatusMap.clear();
|
||||||
|
try {
|
||||||
|
const resp = await API.getWorkspaceGitStatus(this._wsId);
|
||||||
|
// resp: { files: [{ path, status, staged }], ahead, behind }
|
||||||
|
const files = resp.files || [];
|
||||||
|
for (const f of files) {
|
||||||
|
// status: 'M' modified, 'A' added, '?' untracked, 'D' deleted, etc.
|
||||||
|
this._gitStatusMap.set(f.path, f.status || '?');
|
||||||
|
}
|
||||||
|
// Apply indicators to rendered tree rows
|
||||||
|
const treeContent = this._els.fileTree.querySelector('.editor-tree-content') || this._els.fileTree;
|
||||||
|
treeContent.querySelectorAll('.editor-tree-row').forEach(row => {
|
||||||
|
const p = row.dataset.path;
|
||||||
|
const st = this._gitStatusMap.get(p);
|
||||||
|
row.classList.remove('git-modified', 'git-added', 'git-untracked', 'git-deleted');
|
||||||
|
if (st === 'M') row.classList.add('git-modified');
|
||||||
|
else if (st === 'A') row.classList.add('git-added');
|
||||||
|
else if (st === '?') row.classList.add('git-untracked');
|
||||||
|
else if (st === 'D') row.classList.add('git-deleted');
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
// Git not configured — ignore
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
_renderFileTree() {
|
_renderFileTree() {
|
||||||
const container = this._els.fileTree;
|
const container = this._els.fileTree.querySelector('.editor-tree-content') || this._els.fileTree;
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
|
|
||||||
if (!this._treeData.length) {
|
if (!this._treeData.length) {
|
||||||
@@ -608,9 +798,10 @@ const EditorMode = {
|
|||||||
_activateTab(path) {
|
_activateTab(path) {
|
||||||
if (this._activeFile === path) return;
|
if (this._activeFile === path) return;
|
||||||
|
|
||||||
// Deactivate previous
|
// Auto-save previous tab if modified (v0.21.6)
|
||||||
if (this._activeFile && this._openFiles.has(this._activeFile)) {
|
if (this._activeFile && this._openFiles.has(this._activeFile)) {
|
||||||
const prev = this._openFiles.get(this._activeFile);
|
const prev = this._openFiles.get(this._activeFile);
|
||||||
|
if (prev.modified) this._saveFile(this._activeFile);
|
||||||
prev.tab.classList.remove('active');
|
prev.tab.classList.remove('active');
|
||||||
prev.editorWrap.style.display = 'none';
|
prev.editorWrap.style.display = 'none';
|
||||||
}
|
}
|
||||||
@@ -627,7 +818,8 @@ const EditorMode = {
|
|||||||
this._updateStatusBar();
|
this._updateStatusBar();
|
||||||
|
|
||||||
// Update file tree active state
|
// Update file tree active state
|
||||||
this._els.fileTree.querySelectorAll('.editor-tree-row').forEach(row => {
|
const treeContent = this._els.fileTree.querySelector('.editor-tree-content') || this._els.fileTree;
|
||||||
|
treeContent.querySelectorAll('.editor-tree-row').forEach(row => {
|
||||||
row.classList.toggle('active', row.dataset.path === path);
|
row.classList.toggle('active', row.dataset.path === path);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -683,6 +875,7 @@ const EditorMode = {
|
|||||||
f.modified = true;
|
f.modified = true;
|
||||||
f.tab.querySelector('.editor-tab-modified').style.display = '';
|
f.tab.querySelector('.editor-tab-modified').style.display = '';
|
||||||
f.tab.classList.add('modified');
|
f.tab.classList.add('modified');
|
||||||
|
if (path === this._activeFile) this._updateStatusBar();
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Save ─────────────────────────────────
|
// ── Save ─────────────────────────────────
|
||||||
@@ -698,6 +891,7 @@ const EditorMode = {
|
|||||||
f.modified = false;
|
f.modified = false;
|
||||||
f.tab.querySelector('.editor-tab-modified').style.display = 'none';
|
f.tab.querySelector('.editor-tab-modified').style.display = 'none';
|
||||||
f.tab.classList.remove('modified');
|
f.tab.classList.remove('modified');
|
||||||
|
if (path === this._activeFile) this._updateStatusBar();
|
||||||
if (typeof UI !== 'undefined') UI.toast(`Saved ${path.split('/').pop()}`, 'success');
|
if (typeof UI !== 'undefined') UI.toast(`Saved ${path.split('/').pop()}`, 'success');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[EditorMode] Save failed:', e);
|
console.error('[EditorMode] Save failed:', e);
|
||||||
@@ -729,6 +923,111 @@ const EditorMode = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Chat Panel Toggle ─────────────────────
|
||||||
|
|
||||||
|
_toggleChat() {
|
||||||
|
this._chatVisible = !this._chatVisible;
|
||||||
|
const toggle = this._els.header.querySelector('#editorChatToggle');
|
||||||
|
if (toggle) toggle.classList.toggle('active', this._chatVisible);
|
||||||
|
|
||||||
|
if (this._chatPane) {
|
||||||
|
this._chatPane.style.display = this._chatVisible ? '' : 'none';
|
||||||
|
}
|
||||||
|
// Hide/show the resize handle
|
||||||
|
const handle = this._els.main?.querySelector('.editor-split-handle');
|
||||||
|
if (handle) handle.style.display = this._chatVisible ? '' : 'none';
|
||||||
|
|
||||||
|
// Let left pane fill when chat is hidden
|
||||||
|
const leftPane = this._els.main?.querySelector('.editor-left-pane');
|
||||||
|
if (leftPane) {
|
||||||
|
if (this._chatVisible) {
|
||||||
|
leftPane.style.flex = `0 0 ${this._splitRatio * 100}%`;
|
||||||
|
} else {
|
||||||
|
leftPane.style.flex = '1';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── New File ─────────────────────────────
|
||||||
|
|
||||||
|
async _createNewFile() {
|
||||||
|
const name = prompt('File name (e.g. notes.md, src/main.go):');
|
||||||
|
if (!name?.trim()) return;
|
||||||
|
// Normalize: strip leading ./ and / — backend expects relative paths
|
||||||
|
const path = name.trim().replace(/^\.?\/+/, '');
|
||||||
|
if (!path) {
|
||||||
|
if (typeof UI !== 'undefined') UI.toast('Invalid file path', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create with minimal content based on extension
|
||||||
|
const ext = path.split('.').pop()?.toLowerCase();
|
||||||
|
let content = '';
|
||||||
|
if (ext === 'md' || ext === 'mdx') {
|
||||||
|
const title = path.split('/').pop().replace(/\.[^.]+$/, '');
|
||||||
|
content = `# ${title}\n\n`;
|
||||||
|
}
|
||||||
|
await API.writeWorkspaceFile(this._wsId, path, content);
|
||||||
|
await this._refreshFileTree();
|
||||||
|
this._openFile(path);
|
||||||
|
} catch (e) {
|
||||||
|
if (typeof UI !== 'undefined') UI.toast(`Failed to create: ${e.message}`, 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Export ───────────────────────────────
|
||||||
|
|
||||||
|
async _exportActiveFile(format) {
|
||||||
|
if (!this._activeFile) {
|
||||||
|
if (typeof UI !== 'undefined') UI.toast('No file open', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const f = this._openFiles.get(this._activeFile);
|
||||||
|
if (!f) return;
|
||||||
|
|
||||||
|
const content = f.editor?.getValue?.() ?? '';
|
||||||
|
const fileName = this._activeFile.split('/').pop();
|
||||||
|
|
||||||
|
switch (format) {
|
||||||
|
case 'md': {
|
||||||
|
const blob = new Blob([content], { type: 'text/plain' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url; a.download = fileName;
|
||||||
|
a.click(); URL.revokeObjectURL(url);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'html': {
|
||||||
|
let html = content;
|
||||||
|
if (window.marked?.parse) {
|
||||||
|
html = marked.parse(content);
|
||||||
|
if (window.DOMPurify?.sanitize) html = DOMPurify.sanitize(html);
|
||||||
|
}
|
||||||
|
const doc = `<!DOCTYPE html><html><head><meta charset="UTF-8">
|
||||||
|
<title>${this._esc(fileName)}</title>
|
||||||
|
<style>body{max-width:720px;margin:40px auto;padding:0 20px;font-family:system-ui,-apple-system,sans-serif;line-height:1.6;color:#222}
|
||||||
|
pre{background:#f5f5f5;padding:16px;border-radius:6px;overflow-x:auto}code{font-size:0.9em}</style>
|
||||||
|
</head><body>${html}</body></html>`;
|
||||||
|
const blob = new Blob([doc], { type: 'text/html' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url; a.download = fileName.replace(/\.[^.]+$/, '.html');
|
||||||
|
a.click(); URL.revokeObjectURL(url);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'clipboard': {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(content);
|
||||||
|
if (typeof UI !== 'undefined') UI.toast('Copied to clipboard', 'success');
|
||||||
|
} catch (e) {
|
||||||
|
if (typeof UI !== 'undefined') UI.toast('Copy failed', 'error');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// ── Language Detection ────────────────────
|
// ── Language Detection ────────────────────
|
||||||
|
|
||||||
_detectLanguage(path) {
|
_detectLanguage(path) {
|
||||||
@@ -780,16 +1079,41 @@ const EditorMode = {
|
|||||||
const fileEl = this._els.footer.querySelector('#editorStatusFile');
|
const fileEl = this._els.footer.querySelector('#editorStatusFile');
|
||||||
const langEl = this._els.footer.querySelector('#editorStatusLang');
|
const langEl = this._els.footer.querySelector('#editorStatusLang');
|
||||||
const cursorEl = this._els.footer.querySelector('#editorStatusCursor');
|
const cursorEl = this._els.footer.querySelector('#editorStatusCursor');
|
||||||
|
const wordsEl = this._els.footer.querySelector('#editorStatusWords');
|
||||||
|
const wcSep = this._els.footer.querySelector('.editor-status-wc-sep');
|
||||||
|
const saveEl = this._els.footer.querySelector('#editorStatusSave');
|
||||||
|
|
||||||
if (this._activeFile) {
|
if (this._activeFile) {
|
||||||
const f = this._openFiles.get(this._activeFile);
|
const f = this._openFiles.get(this._activeFile);
|
||||||
if (fileEl) fileEl.textContent = this._activeFile;
|
if (fileEl) fileEl.textContent = this._activeFile;
|
||||||
if (langEl) langEl.textContent = f?.language || '';
|
if (langEl) langEl.textContent = f?.language || '';
|
||||||
if (cursorEl) cursorEl.textContent = '';
|
if (cursorEl) cursorEl.textContent = '';
|
||||||
|
|
||||||
|
// Word count for text-heavy files
|
||||||
|
const textLangs = ['markdown', 'text', ''];
|
||||||
|
const isText = textLangs.includes(f?.language || '') ||
|
||||||
|
this._activeFile.match(/\.(md|mdx|txt|rst|adoc)$/i);
|
||||||
|
if (isText && f?.editor?.getValue) {
|
||||||
|
const text = f.editor.getValue();
|
||||||
|
const words = text.trim() ? text.trim().split(/\s+/).length : 0;
|
||||||
|
const readMin = Math.max(1, Math.ceil(words / 230));
|
||||||
|
if (wordsEl) { wordsEl.textContent = `${words} words · ${readMin} min`; wordsEl.style.display = ''; }
|
||||||
|
if (wcSep) wcSep.style.display = '';
|
||||||
|
} else {
|
||||||
|
if (wordsEl) wordsEl.style.display = 'none';
|
||||||
|
if (wcSep) wcSep.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save indicator
|
||||||
|
if (saveEl) saveEl.textContent = f?.modified ? '● Modified' : '';
|
||||||
|
if (saveEl) saveEl.className = 'editor-status-save' + (f?.modified ? ' unsaved' : '');
|
||||||
} else {
|
} else {
|
||||||
if (fileEl) fileEl.textContent = 'No file open';
|
if (fileEl) fileEl.textContent = 'No file open';
|
||||||
if (langEl) langEl.textContent = '';
|
if (langEl) langEl.textContent = '';
|
||||||
if (cursorEl) cursorEl.textContent = '';
|
if (cursorEl) cursorEl.textContent = '';
|
||||||
|
if (wordsEl) wordsEl.style.display = 'none';
|
||||||
|
if (wcSep) wcSep.style.display = 'none';
|
||||||
|
if (saveEl) saveEl.textContent = '';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -871,6 +1195,13 @@ const EditorMode = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ctrl/Cmd+Shift+F — workspace search (same as quick open)
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'F') {
|
||||||
|
e.preventDefault();
|
||||||
|
EditorMode._showQuickOpen();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Ctrl/Cmd+W — close tab
|
// Ctrl/Cmd+W — close tab
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 'w') {
|
if ((e.ctrlKey || e.metaKey) && e.key === 'w') {
|
||||||
if (EditorMode._activeFile) {
|
if (EditorMode._activeFile) {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ async function loadProjects() {
|
|||||||
description: p.description || '',
|
description: p.description || '',
|
||||||
color: p.color || null,
|
color: p.color || null,
|
||||||
icon: p.icon || null,
|
icon: p.icon || null,
|
||||||
|
workspace_id: p.workspace_id || null,
|
||||||
channelCount: p.channel_count || 0,
|
channelCount: p.channel_count || 0,
|
||||||
kbCount: p.kb_count || 0,
|
kbCount: p.kb_count || 0,
|
||||||
noteCount: p.note_count || 0,
|
noteCount: p.note_count || 0,
|
||||||
|
|||||||
322
src/js/router.js
Normal file
322
src/js/router.js
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
// ==========================================
|
||||||
|
// Chat Switchboard – Hash Router (v0.21.6)
|
||||||
|
// ==========================================
|
||||||
|
// URL-driven navigation. The hash IS the entry point.
|
||||||
|
//
|
||||||
|
// #/chat → default chat view
|
||||||
|
// #/chat/ch_abc123 → open specific chat
|
||||||
|
// #/editor → editor surface (workspace picker if none)
|
||||||
|
// #/editor/ws_abc123 → editor with specific workspace
|
||||||
|
//
|
||||||
|
// Bookmarkable. Browser back/forward works.
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
const Router = {
|
||||||
|
|
||||||
|
_navigating: false, // prevents circular hash updates
|
||||||
|
_pending: null, // queued route when surface isn't registered yet
|
||||||
|
_initialized: false,
|
||||||
|
|
||||||
|
// ── Public API ───────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize router. Call once after Surfaces.init(), loadChats(), etc.
|
||||||
|
* Reads the current hash and routes to it.
|
||||||
|
*/
|
||||||
|
init() {
|
||||||
|
window.addEventListener('hashchange', () => this._onHashChange());
|
||||||
|
|
||||||
|
// Listen for surface registration to resolve pending routes
|
||||||
|
Events.on('surface.registered', () => this._tryPending());
|
||||||
|
|
||||||
|
// Keep hash in sync when surfaces or chats change externally
|
||||||
|
Events.on('surface.activated', (ev) => {
|
||||||
|
if (!this._navigating) this._syncHash();
|
||||||
|
});
|
||||||
|
|
||||||
|
this._initialized = true;
|
||||||
|
this.resolve();
|
||||||
|
console.log('[Router] Initialized');
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate programmatically.
|
||||||
|
* @param {string} path — e.g. '/editor/ws_abc123'
|
||||||
|
*/
|
||||||
|
navigate(path) {
|
||||||
|
const hash = '#' + (path.startsWith('/') ? path : '/' + path);
|
||||||
|
if (location.hash === hash) {
|
||||||
|
this.resolve(); // re-resolve even if same hash
|
||||||
|
} else {
|
||||||
|
location.hash = hash;
|
||||||
|
// hashchange event will fire → _onHashChange → resolve
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse current hash and execute the route.
|
||||||
|
*/
|
||||||
|
resolve() {
|
||||||
|
const route = this._parse();
|
||||||
|
console.log('[Router] Resolving:', route);
|
||||||
|
|
||||||
|
this._navigating = true;
|
||||||
|
|
||||||
|
switch (route.surface) {
|
||||||
|
|
||||||
|
case 'chat':
|
||||||
|
this._routeChat(route);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'editor':
|
||||||
|
this._routeEditor(route);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Unknown route → default to chat
|
||||||
|
this._routeChat({ surface: 'chat', id: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
this._navigating = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update hash to reflect current app state.
|
||||||
|
* Called after external navigation (e.g. sidebar click).
|
||||||
|
*/
|
||||||
|
update(surface, params = {}) {
|
||||||
|
if (this._navigating) return;
|
||||||
|
let hash = '#/' + (surface || 'chat');
|
||||||
|
if (params.chatId) hash += '/' + params.chatId;
|
||||||
|
if (params.wsId) hash += '/' + params.wsId;
|
||||||
|
if (params.path) hash += '/' + params.path;
|
||||||
|
|
||||||
|
if (location.hash !== hash) {
|
||||||
|
this._navigating = true;
|
||||||
|
history.replaceState(null, '', hash);
|
||||||
|
this._navigating = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Route Handlers ───────────────────────
|
||||||
|
|
||||||
|
_routeChat(route) {
|
||||||
|
// Ensure we're on chat surface
|
||||||
|
if (Surfaces.getCurrent() !== 'chat') {
|
||||||
|
Surfaces.activate('chat');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (route.id) {
|
||||||
|
const chat = App.chats?.find(c => c.id === route.id);
|
||||||
|
if (chat) {
|
||||||
|
selectChat(route.id);
|
||||||
|
} else {
|
||||||
|
console.warn(`[Router] Chat ${route.id} not found`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No id → just show empty state (newChat was already showing)
|
||||||
|
},
|
||||||
|
|
||||||
|
_routeEditor(route) {
|
||||||
|
const wsId = route.id || null;
|
||||||
|
|
||||||
|
// If EditorMode is already registered, activate directly
|
||||||
|
if (Surfaces.get('editor')) {
|
||||||
|
// Update workspace if specified and different
|
||||||
|
if (wsId && typeof EditorMode !== 'undefined' && EditorMode._wsId !== wsId) {
|
||||||
|
EditorMode.openDirect(wsId);
|
||||||
|
}
|
||||||
|
Surfaces.activate('editor');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Surface not registered yet — try to register it
|
||||||
|
if (wsId && typeof EditorMode !== 'undefined') {
|
||||||
|
EditorMode.openDirect(wsId);
|
||||||
|
// After registration, activate
|
||||||
|
if (Surfaces.get('editor')) {
|
||||||
|
Surfaces.activate('editor');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No workspace specified — try active project's workspace
|
||||||
|
if (!wsId && typeof EditorMode !== 'undefined') {
|
||||||
|
const projWsId = this._activeProjectWorkspace();
|
||||||
|
if (projWsId) {
|
||||||
|
EditorMode.openDirect(projWsId);
|
||||||
|
if (Surfaces.get('editor')) {
|
||||||
|
Surfaces.activate('editor');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Still not registered — show workspace picker
|
||||||
|
if (!Surfaces.get('editor')) {
|
||||||
|
this._pending = route;
|
||||||
|
this._showWorkspacePicker('editor');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Workspace Picker (inline) ────────────
|
||||||
|
|
||||||
|
async _showWorkspacePicker(targetSurface) {
|
||||||
|
let workspaces = [];
|
||||||
|
try {
|
||||||
|
const resp = await API.listWorkspaces();
|
||||||
|
workspaces = Array.isArray(resp) ? resp : (resp?.data || []);
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
if (workspaces.length === 1) {
|
||||||
|
// Auto-select single workspace
|
||||||
|
this._pickWorkspace(targetSurface, workspaces[0].id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build overlay
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'router-picker-overlay';
|
||||||
|
|
||||||
|
const label = 'Editor';
|
||||||
|
let inner = `
|
||||||
|
<div class="router-picker">
|
||||||
|
<h3>Open ${label}</h3>
|
||||||
|
<p>Choose a workspace to get started:</p>
|
||||||
|
<div class="router-picker-list">`;
|
||||||
|
|
||||||
|
if (workspaces.length === 0) {
|
||||||
|
inner += '<div class="router-picker-empty">No workspaces yet</div>';
|
||||||
|
}
|
||||||
|
for (const ws of workspaces) {
|
||||||
|
inner += `<button class="router-picker-item" data-ws="${ws.id}">
|
||||||
|
<span class="router-picker-icon">📁</span>
|
||||||
|
<span>${this._esc(ws.name || ws.id.slice(0, 8))}</span>
|
||||||
|
</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
inner += `</div>
|
||||||
|
<div class="router-picker-actions">
|
||||||
|
<button class="btn-small btn-primary" id="routerPickerNew">+ New workspace</button>
|
||||||
|
<button class="btn-small" id="routerPickerCancel">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
overlay.innerHTML = inner;
|
||||||
|
|
||||||
|
// Wire clicks
|
||||||
|
overlay.querySelectorAll('.router-picker-item').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
overlay.remove();
|
||||||
|
this._pickWorkspace(targetSurface, btn.dataset.ws);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
overlay.querySelector('#routerPickerNew')?.addEventListener('click', async () => {
|
||||||
|
const name = prompt('Workspace name:');
|
||||||
|
if (!name?.trim()) return;
|
||||||
|
try {
|
||||||
|
const ws = await API.createWorkspace({
|
||||||
|
name: name.trim(),
|
||||||
|
owner_type: 'user',
|
||||||
|
owner_id: API.user?.id || '',
|
||||||
|
});
|
||||||
|
overlay.remove();
|
||||||
|
this._pickWorkspace(targetSurface, ws.id);
|
||||||
|
} catch (e) {
|
||||||
|
if (typeof UI !== 'undefined') UI.toast('Failed: ' + (e.message || e), 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
overlay.querySelector('#routerPickerCancel')?.addEventListener('click', () => {
|
||||||
|
overlay.remove();
|
||||||
|
this._pending = null;
|
||||||
|
this.navigate('/chat');
|
||||||
|
});
|
||||||
|
|
||||||
|
overlay.addEventListener('click', (e) => {
|
||||||
|
if (e.target === overlay) {
|
||||||
|
overlay.remove();
|
||||||
|
this._pending = null;
|
||||||
|
this.navigate('/chat');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
},
|
||||||
|
|
||||||
|
_pickWorkspace(targetSurface, wsId) {
|
||||||
|
if (typeof EditorMode !== 'undefined') {
|
||||||
|
EditorMode.openDirect(wsId);
|
||||||
|
if (Surfaces.get('editor')) Surfaces.activate('editor');
|
||||||
|
}
|
||||||
|
// Update hash
|
||||||
|
this.navigate('/editor/' + wsId);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Hash Parsing ─────────────────────────
|
||||||
|
|
||||||
|
_parse() {
|
||||||
|
const hash = (location.hash || '').replace(/^#\/?/, '');
|
||||||
|
const parts = hash.split('/').filter(Boolean);
|
||||||
|
|
||||||
|
if (!parts.length) return { surface: 'chat', id: null };
|
||||||
|
|
||||||
|
const surface = parts[0];
|
||||||
|
const id = parts[1] || null;
|
||||||
|
const extra = parts.length > 2 ? parts.slice(2).join('/') : null;
|
||||||
|
|
||||||
|
return { surface, id, extra };
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Event Handlers ───────────────────────
|
||||||
|
|
||||||
|
_onHashChange() {
|
||||||
|
if (this._navigating) return;
|
||||||
|
this.resolve();
|
||||||
|
},
|
||||||
|
|
||||||
|
_tryPending() {
|
||||||
|
if (!this._pending) return;
|
||||||
|
const route = this._pending;
|
||||||
|
|
||||||
|
// Check if the target surface is now registered
|
||||||
|
if (Surfaces.get(route.surface)) {
|
||||||
|
this._pending = null;
|
||||||
|
this._navigating = true;
|
||||||
|
Surfaces.activate(route.surface);
|
||||||
|
this._navigating = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync hash to current app state (called when navigation happens
|
||||||
|
* outside the router, e.g. clicking a chat in the sidebar).
|
||||||
|
*/
|
||||||
|
_syncHash() {
|
||||||
|
const surface = Surfaces.getCurrent();
|
||||||
|
const params = {};
|
||||||
|
|
||||||
|
if (surface === 'chat' && App.currentChatId) {
|
||||||
|
params.chatId = App.currentChatId;
|
||||||
|
} else if (surface === 'editor' && typeof EditorMode !== 'undefined') {
|
||||||
|
params.wsId = EditorMode._wsId;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.update(surface, params);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────
|
||||||
|
|
||||||
|
_activeProjectWorkspace() {
|
||||||
|
if (!App.activeProjectId) return null;
|
||||||
|
const proj = App.projects?.find(p => p.id === App.activeProjectId);
|
||||||
|
return proj?.workspace_id || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
_esc(s) {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.textContent = s || '';
|
||||||
|
return d.innerHTML;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
// Chat Switchboard – Surface Registry
|
// Chat Switchboard – Surface Registry
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Manages "modes" (surfaces) — chat, editor, article, etc.
|
// Manages "modes" (surfaces) — chat, editor, etc.
|
||||||
// Each surface can take over named regions of the UI without
|
// Each surface can take over named regions of the UI without
|
||||||
// destroying the DOM nodes of the previous surface.
|
// destroying the DOM nodes of the previous surface.
|
||||||
//
|
//
|
||||||
@@ -308,7 +308,7 @@ const Surfaces = {
|
|||||||
btn.className = 'mode-btn' + (id === this._current ? ' active' : '');
|
btn.className = 'mode-btn' + (id === this._current ? ' active' : '');
|
||||||
btn.dataset.surface = id;
|
btn.dataset.surface = id;
|
||||||
btn.title = def.label;
|
btn.title = def.label;
|
||||||
btn.innerHTML = this._iconSvg(def.icon);
|
btn.innerHTML = `${this._iconSvg(def.icon)}<span class="mode-btn-label">${def.label}</span>`;
|
||||||
btn.addEventListener('click', () => this.activate(id));
|
btn.addEventListener('click', () => this.activate(id));
|
||||||
wrap.appendChild(btn);
|
wrap.appendChild(btn);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user