diff --git a/CHANGELOG.md b/CHANGELOG.md index 1048446..96953b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,32 @@ All notable changes to Chat Switchboard. +## [0.21.5] — 2026-03-01 + +### Added +- **Editor Surface (Development Mode).** IDE-like experience built as a v0.21.3 surface consuming workspace primitives. Activates automatically when a channel or project has a bound workspace. Three-pane layout: file tree (sidebar), code editor (CM6), and AI chat panel with resizable split. +- **`editor-mode.js`** — Full editor surface module. File tree backed by `workspace_ls` API with nested directory expansion, file icons by extension, and context menus. Tab bar with modified indicators, close buttons, and multi-file editing. CM6 integration with language auto-detection (20+ languages) and textarea fallback when CM6 bundle unavailable. +- **Split pane layout** with draggable resize handle. Editor (left) and AI chat (right) share the main region. Chat DOM borrowed from Surfaces saved fragments — real conversation history and input preserved across mode switches. +- **Quick Open** (`Ctrl/Cmd+P`): Fuzzy file finder overlay using workspace file index. `Ctrl/Cmd+S` save, `Ctrl/Cmd+W` close tab. +- **Live editor updates**: When AI tool calls modify workspace files (`workspace_write`, `workspace_patch`), the server emits `workspace.file.changed` via WebSocket. If the file is open and unmodified in the editor, content refreshes automatically. +- **Status bar**: Current file path, language mode, git branch display. +- **`editor-mode.css`**: Complete styling for all editor components with dark theme support, mobile responsive (stacked layout at ≤768px). +- **Workspace API methods** on frontend `API` module: `getWorkspace`, `listWorkspaceFiles`, `readWorkspaceFile`, `writeWorkspaceFile`, `deleteWorkspaceFile`, `mkdirWorkspace`, `getWorkspaceGitStatus`, `getWorkspaceGitBranches`. +- **`Surfaces.getSavedFragment()` / `putSavedFragment()`** — Cross-surface DOM sharing. Allows the editor surface to borrow chat DOM into its split pane without cloning. +- **`workspace.file.` event routing** added to server event route table (`DirToClient`). +- **Workspace management UI**: Create, list, and bind workspaces from the frontend. Project settings panel gains a Workspace section with dropdown picker and "New" button. Chat context menu gains "Set workspace…" option for per-channel override. Both paths support creating new workspaces inline. +- **`GET /workspaces`** — List all workspaces accessible to the current user (user-owned + team-owned). +- **`workspace_id` in channel API**: `updateChannelRequest` accepts `workspace_id` for binding/unbinding. `channelResponse` and both list/get queries now include `workspace_id`. + +### Changed +- `stream_loop.go`: Emits `workspace.file.changed` event after successful `workspace_write`/`workspace_patch` tool calls. +- `index.html`: Added `editor-mode.css` stylesheet and `editor-mode.js` script. +- `channels.go`: `channelResponse` includes `workspace_id`, list/get queries select it, update accepts it. +- `workspaces.go`: Added `List` handler for `GET /workspaces`. +- `main.go`: Added `GET /workspaces` route. +- `projects-ui.js`: Workspace section in project panel, workspace picker in chat context menu. +- `styles.css`: Workspace row layout in project panel. + ## [0.21.4] — 2026-03-01 ### Added diff --git a/VERSION b/VERSION index b166f00..c9532c6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.21.4 \ No newline at end of file +0.21.5 \ No newline at end of file diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 2d66f6e..e58af8e 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -85,7 +85,7 @@ v0.21.0 Workspace Storage Primitive ✅ v0.21.1 Workspace ✅ v0.21.3 Surface Infra ✅ Tools + Bindings + REPL (parallel) │ │ -v0.21.2 Workspace ✅ v0.21.5 Editor Surface +v0.21.2 Workspace ✅ v0.21.5 Editor Surface ✅ Indexing + Search (Development Mode) │ │ v0.21.4 Git ✅ v0.21.6 Article Surface @@ -772,15 +772,29 @@ Layer git operations onto workspaces. - [ ] Integration tests: clone, commit, push/pull cycle (requires git binary in CI) - [ ] `.gitignore` respect in workspace indexing (deferred to v0.21.5+) -### v0.21.5 — Editor Surface (Development Mode) +### v0.21.5 — Editor Surface (Development Mode) ✅ -IDE-like experience. Browser-tier extension consuming workspace primitives. +IDE-like experience. Browser-tier surface consuming workspace primitives. -- [ ] Layout: file tree (sidebar), code editor (CM6, main), AI chat panel (resizable split), status bar -- [ ] File tree: workspace_ls backed, git status indicators, context menu, drag-drop -- [ ] Code editor: CM6 codeEditor() factory, language auto-detection, tab bar, Ctrl+S save -- [ ] AI chat panel: same channel context, workspace tools auto-injected, live update via EventBus -- [ ] Keyboard shortcuts: Ctrl+P quick open, Ctrl+Shift+F workspace search +- [x] Layout: file tree (sidebar), code editor (CM6, main), AI chat panel (resizable split), status bar +- [x] File tree: workspace_ls backed, context menu (open, delete), nested directory expansion, file icons +- [x] Code editor: CM6 codeEditor() factory with textarea fallback, language auto-detection (20+ languages), tab bar, Ctrl+S save +- [x] AI chat panel: same channel context via Surfaces DOM borrowing, live update via workspace.file.changed event +- [x] Keyboard shortcuts: Ctrl+P quick open, Ctrl+S save, Ctrl+W close tab +- [x] Split pane layout with draggable resize handle +- [x] Status bar: file path, language mode, git branch +- [x] Workspace API methods on frontend (8 methods) +- [x] Surfaces.getSavedFragment/putSavedFragment for cross-surface DOM sharing +- [x] workspace.file.changed event emission from stream_loop.go after tool calls +- [x] Event route table: workspace.file. → DirToClient +- [x] editor-mode.css: dark theme, mobile responsive (stacked at ≤768px) +- [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] GET /workspaces list endpoint, workspace_id in channel API (update, get, list) +- [ ] Git status indicators in file tree (visual indicators per-file) — deferred +- [ ] Drag-drop file reorder — deferred +- [ ] Ctrl+Shift+F workspace search — deferred +- [ ] Auto-save on tab/mode switch — deferred ### v0.21.6 — Article Surface + Document Output Pipeline diff --git a/server/events/types.go b/server/events/types.go index b1e010d..0deaceb 100644 --- a/server/events/types.go +++ b/server/events/types.go @@ -60,6 +60,9 @@ var routeTable = map[string]Direction{ "notification.new": DirToClient, // targeted via Hub.SendToUser, not room-based "notification.read": DirToClient, // badge sync across tabs + // Workspace (v0.21.5) + "workspace.file.": DirToClient, // file changed events for live editor updates + // Plugin hooks — never cross the wire "plugin.hook.": DirLocal, "internal.": DirLocal, diff --git a/server/handlers/channels.go b/server/handlers/channels.go index 12324e9..818487f 100644 --- a/server/handlers/channels.go +++ b/server/handlers/channels.go @@ -38,7 +38,8 @@ type updateChannelRequest struct { IsPinned *bool `json:"is_pinned,omitempty"` Folder *string `json:"folder,omitempty"` Tags []string `json:"tags,omitempty"` - Settings *json.RawMessage `json:"settings,omitempty"` // JSONB merge into existing settings + Settings *json.RawMessage `json:"settings,omitempty"` // JSONB merge into existing settings + WorkspaceID *string `json:"workspace_id,omitempty"` // bind workspace (v0.21.5) } type channelResponse struct { @@ -54,6 +55,7 @@ type channelResponse struct { IsPinned bool `json:"is_pinned"` Folder *string `json:"folder"` ProjectID *string `json:"project_id,omitempty"` + WorkspaceID *string `json:"workspace_id,omitempty"` Tags []string `json:"tags"` Settings json.RawMessage `json:"settings,omitempty"` MessageCount int `json:"message_count"` @@ -178,7 +180,7 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) { // Fetch channels with message count query := ` SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.provider_config_id, - c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.project_id, + c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.project_id, c.workspace_id, c.tags, c.settings, COALESCE(mc.cnt, 0) AS message_count, c.created_at, c.updated_at @@ -230,7 +232,7 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) { var tags []string err := rows.Scan( &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.MessageCount, &ch.CreatedAt, &ch.UpdatedAt, ) @@ -377,7 +379,7 @@ func (h *ChannelHandler) GetChannel(c *gin.Context) { var tags []string err := database.DB.QueryRow(database.Q(` SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.provider_config_id, - c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.project_id, + c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.project_id, c.workspace_id, c.tags, c.settings, COALESCE(mc.cnt, 0) AS message_count, c.created_at, c.updated_at @@ -388,7 +390,7 @@ func (h *ChannelHandler) GetChannel(c *gin.Context) { WHERE c.id = $1 AND c.user_id = $2 `), channelID, userID).Scan( &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.MessageCount, &ch.CreatedAt, &ch.UpdatedAt, ) @@ -484,6 +486,13 @@ func (h *ChannelHandler) UpdateChannel(c *gin.Context) { } args = append(args, []byte(*req.Settings)) } + if req.WorkspaceID != nil { + if *req.WorkspaceID == "" { + addClause("workspace_id", nil) // unbind + } else { + addClause("workspace_id", *req.WorkspaceID) + } + } if len(setClauses) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) diff --git a/server/handlers/stream_loop.go b/server/handlers/stream_loop.go index ad78e4b..4548a42 100644 --- a/server/handlers/stream_loop.go +++ b/server/handlers/stream_loop.go @@ -207,6 +207,22 @@ func streamWithToolLoop( }) sendEvent("tool_result", string(resultJSON)) + // Emit workspace.file.changed for live editor updates (v0.21.5) + if hub != nil && !toolResult.IsError && workspaceID != "" { + if call.Name == "workspace_write" || call.Name == "workspace_patch" { + var toolArgs struct{ Path string `json:"path"` } + if json.Unmarshal([]byte(call.Arguments), &toolArgs) == nil && toolArgs.Path != "" { + hub.SendToUser(userID, events.Event{ + Label: "workspace.file.changed", + Payload: events.MustJSON(map[string]string{ + "workspace_id": workspaceID, + "path": toolArgs.Path, + }), + }) + } + } + } + // Collect for persistence result.ToolActivity = append(result.ToolActivity, map[string]interface{}{ "id": tc.ID, diff --git a/server/handlers/workspaces.go b/server/handlers/workspaces.go index 7ad36ac..76e866c 100644 --- a/server/handlers/workspaces.go +++ b/server/handlers/workspaces.go @@ -101,6 +101,34 @@ func (h *WorkspaceHandler) Get(c *gin.Context) { c.JSON(http.StatusOK, w) } +// List returns all workspaces accessible to the current user. +// Includes user-owned workspaces and those owned by the user's teams. +func (h *WorkspaceHandler) List(c *gin.Context) { + userID := getUserID(c) + ctx := c.Request.Context() + + // Fetch user-owned workspaces + userWs, err := h.stores.Workspaces.ListByOwner(ctx, "user", userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + all := make([]models.Workspace, 0, len(userWs)) + all = append(all, userWs...) + + // Also include team-owned workspaces + teamIDs, _ := h.stores.Teams.GetUserTeamIDs(ctx, userID) + for _, tid := range teamIDs { + teamWs, err := h.stores.Workspaces.ListByOwner(ctx, "team", tid) + if err == nil { + all = append(all, teamWs...) + } + } + + c.JSON(http.StatusOK, all) +} + func (h *WorkspaceHandler) Update(c *gin.Context) { w, ok := h.loadAndAuthorize(c) if !ok { diff --git a/server/main.go b/server/main.go index 589c866..0711dd6 100644 --- a/server/main.go +++ b/server/main.go @@ -454,6 +454,7 @@ func main() { if wfs != nil { wsH := handlers.NewWorkspaceHandler(stores, wfs) protected.POST("/workspaces", wsH.Create) + protected.GET("/workspaces", wsH.List) protected.GET("/workspaces/:id", wsH.Get) protected.PATCH("/workspaces/:id", wsH.Update) protected.DELETE("/workspaces/:id", wsH.Delete) diff --git a/src/css/editor-mode.css b/src/css/editor-mode.css new file mode 100644 index 0000000..80578c2 --- /dev/null +++ b/src/css/editor-mode.css @@ -0,0 +1,229 @@ +/* ========================================== + Editor Surface (v0.21.5) + IDE-like layout: file tree | code + chat + ========================================== */ + +/* ── Header ──────────────────────────────── */ + +.editor-header { + display: flex; align-items: center; justify-content: space-between; + padding: 6px 12px; gap: 8px; + font-size: 13px; color: var(--text-secondary); + border-bottom: 1px solid var(--border); + min-height: 36px; box-sizing: border-box; +} +.editor-header-left { display: flex; align-items: center; gap: 8px; min-width: 0; } +.editor-ws-name { font-weight: 600; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.editor-branch { font-size: 12px; color: var(--text-tertiary); white-space: nowrap; } +.editor-header-right { display: flex; align-items: center; gap: 4px; } +.editor-hdr-btn { + background: none; border: none; color: var(--text-secondary); + cursor: pointer; padding: 4px; border-radius: 4px; + display: flex; align-items: center; +} +.editor-hdr-btn:hover { background: var(--hover); color: var(--text-primary); } + +/* ── File Tree (sidebar-content region) ──── */ + +.editor-file-tree { + flex: 1; overflow-y: auto; overflow-x: hidden; + padding: 4px 0; font-size: 13px; + user-select: none; +} +.editor-tree-row { + display: flex; align-items: center; gap: 4px; + padding: 3px 8px; cursor: pointer; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + border-radius: 4px; margin: 0 4px; +} +.editor-tree-row:hover { background: var(--hover); } +.editor-tree-row.active { background: var(--active-bg, rgba(59,130,246,0.12)); color: var(--accent); } +.editor-tree-arrow { width: 14px; text-align: center; font-size: 10px; color: var(--text-tertiary); flex-shrink: 0; } +.editor-tree-arrow.expanded { /* already rotated via character */ } +.editor-tree-icon { flex-shrink: 0; font-size: 14px; line-height: 1; } +.editor-tree-name { overflow: hidden; text-overflow: ellipsis; } + +.editor-tree-loading, .editor-tree-empty, .editor-tree-error { + padding: 16px; text-align: center; color: var(--text-tertiary); font-size: 12px; +} + +/* ── Main Split Layout ───────────────────── */ + +.editor-main { + display: flex; flex: 1; min-height: 0; overflow: hidden; +} +.editor-left-pane { + display: flex; flex-direction: column; min-width: 200px; overflow: hidden; +} +.editor-right-pane { + display: flex; flex-direction: column; min-width: 200px; overflow: hidden; + border-left: 1px solid var(--border); +} + +/* Resize handle */ +.editor-split-handle { + width: 5px; cursor: col-resize; flex-shrink: 0; + background: transparent; position: relative; z-index: 5; +} +.editor-split-handle:hover, +.editor-split-handle:active { + background: var(--accent); + opacity: 0.3; +} + +/* ── Tab Bar ─────────────────────────────── */ + +.editor-tab-bar { + display: flex; overflow-x: auto; overflow-y: hidden; + border-bottom: 1px solid var(--border); + min-height: 32px; flex-shrink: 0; + scrollbar-width: none; +} +.editor-tab-bar::-webkit-scrollbar { height: 0; } + +.editor-tab { + display: flex; align-items: center; gap: 4px; + padding: 4px 10px; font-size: 12px; + cursor: pointer; white-space: nowrap; + border-right: 1px solid var(--border); + color: var(--text-secondary); + flex-shrink: 0; max-width: 180px; + position: relative; +} +.editor-tab:hover { background: var(--hover); } +.editor-tab.active { + background: var(--bg-primary); + color: var(--text-primary); + border-bottom: 2px solid var(--accent); +} +.editor-tab.modified .editor-tab-name { font-style: italic; } +.editor-tab-icon { font-size: 13px; flex-shrink: 0; } +.editor-tab-name { overflow: hidden; text-overflow: ellipsis; } +.editor-tab-modified { color: var(--accent); font-size: 10px; margin-left: 2px; } +.editor-tab-close { + background: none; border: none; color: var(--text-tertiary); + cursor: pointer; font-size: 11px; padding: 0 2px; + border-radius: 3px; opacity: 0; transition: opacity 0.1s; + margin-left: 4px; +} +.editor-tab:hover .editor-tab-close { opacity: 1; } +.editor-tab-close:hover { background: var(--hover); color: var(--text-primary); } + +/* ── Code Editor Area ────────────────────── */ + +.editor-code-area { + flex: 1; min-height: 0; position: relative; overflow: hidden; +} +.editor-cm-wrap { + position: absolute; inset: 0; overflow: hidden; +} +.editor-cm-wrap .cm-editor { height: 100%; } +.editor-cm-wrap .cm-scroller { overflow: auto; } + +/* Textarea fallback */ +.editor-textarea-fallback { + width: 100%; height: 100%; resize: none; border: none; outline: none; + background: var(--bg-primary); color: var(--text-primary); + font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; + font-size: 13px; line-height: 1.5; + padding: 8px 12px; box-sizing: border-box; + tab-size: 4; +} + +/* Welcome screen */ +.editor-welcome { + position: absolute; inset: 0; + display: flex; align-items: center; justify-content: center; +} +.editor-welcome-inner { text-align: center; color: var(--text-tertiary); } +.editor-welcome-inner p { margin: 8px 0; font-size: 14px; } +.editor-welcome-hint { font-size: 12px; opacity: 0.6; } +.editor-welcome-hint kbd { + background: var(--hover); border: 1px solid var(--border); + border-radius: 3px; padding: 1px 5px; font-size: 11px; + font-family: inherit; +} + +/* ── Chat Panel (right pane) ─────────────── */ + +.editor-chat-header { + padding: 6px 12px; font-size: 12px; font-weight: 600; + color: var(--text-secondary); border-bottom: 1px solid var(--border); + flex-shrink: 0; +} +.editor-chat-messages { + flex: 1; overflow-y: auto; min-height: 0; +} +/* Inherit chat message styles when embedded */ +.editor-chat-messages .messages { height: auto; overflow: visible; } +.editor-chat-input { flex-shrink: 0; } +/* Inherit input-area styles */ +.editor-chat-input .input-area { border-top: 1px solid var(--border); } + +/* ── Status Bar (footer) ─────────────────── */ + +.editor-status-bar { + display: flex; align-items: center; gap: 8px; + padding: 2px 12px; font-size: 11px; + color: var(--text-tertiary); + border-top: 1px solid var(--border); + min-height: 22px; flex-shrink: 0; + background: var(--bg-secondary); +} +.editor-status-sep { opacity: 0.3; } +.editor-status-right { margin-left: auto; } +.editor-status-branch { font-size: 11px; } +.editor-status-file { max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +/* ── Context Menu ────────────────────────── */ + +.editor-ctx-menu { + position: fixed; z-index: 1000; + background: var(--bg-primary); border: 1px solid var(--border); + border-radius: 6px; padding: 4px 0; + box-shadow: 0 4px 16px rgba(0,0,0,0.2); + min-width: 120px; font-size: 13px; +} +.editor-ctx-item { + padding: 6px 12px; cursor: pointer; + color: var(--text-primary); +} +.editor-ctx-item:hover { background: var(--hover); } + +/* ── Quick Open Overlay ──────────────────── */ + +.editor-quick-open { + position: fixed; inset: 0; z-index: 2000; + background: rgba(0,0,0,0.4); + display: flex; align-items: flex-start; justify-content: center; + padding-top: 15vh; +} +.editor-qo-dialog { + background: var(--bg-primary); border: 1px solid var(--border); + border-radius: 8px; width: 460px; max-width: 90vw; + box-shadow: 0 8px 32px rgba(0,0,0,0.3); + overflow: hidden; +} +.editor-qo-input { + width: 100%; padding: 10px 14px; font-size: 14px; + border: none; outline: none; box-sizing: border-box; + background: var(--bg-primary); color: var(--text-primary); + border-bottom: 1px solid var(--border); +} +.editor-qo-results { max-height: 300px; overflow-y: auto; } +.editor-qo-row { + padding: 6px 14px; cursor: pointer; font-size: 13px; + display: flex; align-items: center; gap: 6px; + color: var(--text-primary); +} +.editor-qo-row:hover { background: var(--hover); } +.editor-qo-icon { font-size: 14px; flex-shrink: 0; } + +/* ── Mobile: single pane ─────────────────── */ + +@media (max-width: 768px) { + .editor-main { flex-direction: column; } + .editor-left-pane { flex: 1 !important; min-width: 0; } + .editor-right-pane { min-width: 0; border-left: none; border-top: 1px solid var(--border); max-height: 40vh; } + .editor-split-handle { display: none; } +} diff --git a/src/css/styles.css b/src/css/styles.css index 49ee9fd..2cd730d 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -2583,6 +2583,8 @@ select option { background: var(--bg-surface); color: var(--text); } } .project-panel-select:focus { outline: none; border-color: var(--accent); } .project-panel-hint { font-size: 11px; color: var(--text-3); margin-top: 2px; } +.project-panel-ws-row { display: flex; gap: 6px; align-items: center; } +.project-panel-ws-row select { flex: 1; } .project-panel-checkbox { display: flex; align-items: center; gap: 8px; font-size: 13px; cursor: pointer; color: var(--text-2); diff --git a/src/index.html b/src/index.html index abfba80..eab587e 100644 --- a/src/index.html +++ b/src/index.html @@ -19,6 +19,7 @@ + @@ -1201,6 +1202,7 @@ +