Changeset 0.21.5 (#91)

This commit is contained in:
2026-03-01 20:35:10 +00:00
parent d67cfd37c2
commit aadba77887
15 changed files with 1499 additions and 14 deletions

View File

@@ -2,6 +2,32 @@
All notable changes to Chat Switchboard. 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 ## [0.21.4] — 2026-03-01
### Added ### Added

View File

@@ -1 +1 @@
0.21.4 0.21.5

View File

@@ -85,7 +85,7 @@ v0.21.0 Workspace Storage Primitive ✅
v0.21.1 Workspace ✅ v0.21.3 Surface Infra ✅ v0.21.1 Workspace ✅ v0.21.3 Surface Infra ✅
Tools + Bindings + REPL (parallel) 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) Indexing + Search (Development Mode)
│ │ │ │
v0.21.4 Git ✅ v0.21.6 Article Surface 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) - [ ] 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 (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 - [x] 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 - [x] File tree: workspace_ls backed, context menu (open, delete), nested directory expansion, file icons
- [ ] Code editor: CM6 codeEditor() factory, language auto-detection, tab bar, Ctrl+S save - [x] Code editor: CM6 codeEditor() factory with textarea fallback, language auto-detection (20+ languages), tab bar, Ctrl+S save
- [ ] AI chat panel: same channel context, workspace tools auto-injected, live update via EventBus - [x] AI chat panel: same channel context via Surfaces DOM borrowing, live update via workspace.file.changed event
- [ ] Keyboard shortcuts: Ctrl+P quick open, Ctrl+Shift+F workspace search - [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 ### v0.21.6 — Article Surface + Document Output Pipeline

View File

@@ -60,6 +60,9 @@ var routeTable = map[string]Direction{
"notification.new": DirToClient, // targeted via Hub.SendToUser, not room-based "notification.new": DirToClient, // targeted via Hub.SendToUser, not room-based
"notification.read": DirToClient, // badge sync across tabs "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 hooks — never cross the wire
"plugin.hook.": DirLocal, "plugin.hook.": DirLocal,
"internal.": DirLocal, "internal.": DirLocal,

View File

@@ -38,7 +38,8 @@ type updateChannelRequest struct {
IsPinned *bool `json:"is_pinned,omitempty"` IsPinned *bool `json:"is_pinned,omitempty"`
Folder *string `json:"folder,omitempty"` Folder *string `json:"folder,omitempty"`
Tags []string `json:"tags,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 { type channelResponse struct {
@@ -54,6 +55,7 @@ type channelResponse struct {
IsPinned bool `json:"is_pinned"` IsPinned bool `json:"is_pinned"`
Folder *string `json:"folder"` Folder *string `json:"folder"`
ProjectID *string `json:"project_id,omitempty"` ProjectID *string `json:"project_id,omitempty"`
WorkspaceID *string `json:"workspace_id,omitempty"`
Tags []string `json:"tags"` Tags []string `json:"tags"`
Settings json.RawMessage `json:"settings,omitempty"` Settings json.RawMessage `json:"settings,omitempty"`
MessageCount int `json:"message_count"` MessageCount int `json:"message_count"`
@@ -178,7 +180,7 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
// Fetch channels with message count // Fetch channels with message count
query := ` query := `
SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.provider_config_id, 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, c.tags, c.settings,
COALESCE(mc.cnt, 0) AS message_count, COALESCE(mc.cnt, 0) AS message_count,
c.created_at, c.updated_at c.created_at, c.updated_at
@@ -230,7 +232,7 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
var tags []string var tags []string
err := rows.Scan( err := rows.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), scanTags(&tags), scanJSON(&ch.Settings),
&ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt, &ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt,
) )
@@ -377,7 +379,7 @@ func (h *ChannelHandler) GetChannel(c *gin.Context) {
var tags []string var tags []string
err := database.DB.QueryRow(database.Q(` err := database.DB.QueryRow(database.Q(`
SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.provider_config_id, 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, c.tags, c.settings,
COALESCE(mc.cnt, 0) AS message_count, COALESCE(mc.cnt, 0) AS message_count,
c.created_at, c.updated_at 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 WHERE c.id = $1 AND c.user_id = $2
`), channelID, userID).Scan( `), channelID, userID).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), scanTags(&tags), scanJSON(&ch.Settings),
&ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt, &ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt,
) )
@@ -484,6 +486,13 @@ func (h *ChannelHandler) UpdateChannel(c *gin.Context) {
} }
args = append(args, []byte(*req.Settings)) 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 { if len(setClauses) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})

View File

@@ -207,6 +207,22 @@ func streamWithToolLoop(
}) })
sendEvent("tool_result", string(resultJSON)) 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 // Collect for persistence
result.ToolActivity = append(result.ToolActivity, map[string]interface{}{ result.ToolActivity = append(result.ToolActivity, map[string]interface{}{
"id": tc.ID, "id": tc.ID,

View File

@@ -101,6 +101,34 @@ func (h *WorkspaceHandler) Get(c *gin.Context) {
c.JSON(http.StatusOK, w) 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) { func (h *WorkspaceHandler) Update(c *gin.Context) {
w, ok := h.loadAndAuthorize(c) w, ok := h.loadAndAuthorize(c)
if !ok { if !ok {

View File

@@ -454,6 +454,7 @@ func main() {
if wfs != nil { if wfs != nil {
wsH := handlers.NewWorkspaceHandler(stores, wfs) wsH := handlers.NewWorkspaceHandler(stores, wfs)
protected.POST("/workspaces", wsH.Create) protected.POST("/workspaces", wsH.Create)
protected.GET("/workspaces", wsH.List)
protected.GET("/workspaces/:id", wsH.Get) protected.GET("/workspaces/:id", wsH.Get)
protected.PATCH("/workspaces/:id", wsH.Update) protected.PATCH("/workspaces/:id", wsH.Update)
protected.DELETE("/workspaces/:id", wsH.Delete) protected.DELETE("/workspaces/:id", wsH.Delete)

229
src/css/editor-mode.css Normal file
View File

@@ -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; }
}

View File

@@ -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-select:focus { outline: none; border-color: var(--accent); }
.project-panel-hint { font-size: 11px; color: var(--text-3); margin-top: 2px; } .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 { .project-panel-checkbox {
display: flex; align-items: center; gap: 8px; font-size: 13px; display: flex; align-items: center; gap: 8px; font-size: 13px;
cursor: pointer; color: var(--text-2); cursor: pointer; color: var(--text-2);

View File

@@ -19,6 +19,7 @@
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<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/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%%">
@@ -1201,6 +1202,7 @@
<script src="js/chat.js?v=%%APP_VERSION%%"></script> <script src="js/chat.js?v=%%APP_VERSION%%"></script>
<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/app.js?v=%%APP_VERSION%%"></script> <script src="js/app.js?v=%%APP_VERSION%%"></script>
<script> <script>
// ── Service Worker Registration ───────── // ── Service Worker Registration ─────────

View File

@@ -207,6 +207,43 @@ const API = {
}, },
listProjectNotes(projectId) { return this._get(`/api/v1/projects/${projectId}/notes`); }, listProjectNotes(projectId) { return this._get(`/api/v1/projects/${projectId}/notes`); },
// ── Workspaces (v0.21.0) ────────────────
getWorkspace(id) { return this._get(`/api/v1/workspaces/${id}`); },
listWorkspaceFiles(wsId, path = '', recursive = false) {
let url = `/api/v1/workspaces/${wsId}/files?path=${encodeURIComponent(path)}`;
if (recursive) url += '&recursive=true';
return this._get(url);
},
async readWorkspaceFile(wsId, path) {
// Returns raw text, not JSON
const resp = await fetch(BASE + `/api/v1/workspaces/${wsId}/files/read?path=${encodeURIComponent(path)}`, {
headers: this._authHeaders(),
});
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw Object.assign(new Error(data.error || `HTTP ${resp.status}`), { status: resp.status });
}
const ct = resp.headers.get('content-type') || '';
if (ct.includes('application/json')) {
const data = await resp.json();
return data.content || '';
}
return resp.text();
},
writeWorkspaceFile(wsId, path, content) {
return this._put(`/api/v1/workspaces/${wsId}/files/write`, { path, content });
},
deleteWorkspaceFile(wsId, path) {
return this._del(`/api/v1/workspaces/${wsId}/files/delete?path=${encodeURIComponent(path)}`);
},
mkdirWorkspace(wsId, path) {
return this._post(`/api/v1/workspaces/${wsId}/files/mkdir`, { path });
},
getWorkspaceGitStatus(wsId) { return this._get(`/api/v1/workspaces/${wsId}/git/status`); },
getWorkspaceGitBranches(wsId) { return this._get(`/api/v1/workspaces/${wsId}/git/branches`); },
listWorkspaces() { return this._get('/api/v1/workspaces'); },
createWorkspace(data) { return this._post('/api/v1/workspaces', data); },
// ── Messages ───────────────────────────── // ── Messages ─────────────────────────────
listMessages(channelId, page = 1, perPage = 200) { listMessages(channelId, page = 1, perPage = 200) {

936
src/js/editor-mode.js Normal file
View File

@@ -0,0 +1,936 @@
// ==========================================
// Chat Switchboard Editor Surface (v0.21.5)
// ==========================================
// IDE-like experience built as a surface consuming workspace
// primitives (v0.21.0) and surface infrastructure (v0.21.3).
//
// Layout: file tree (sidebar) | code editor (CM6) + chat panel (split)
// Load order: surfaces.js → editor-mode.js (after app init)
// ==========================================
const EditorMode = {
// ── State ────────────────────────────────
_wsId: null, // workspace ID
_wsName: '', // workspace display name
_gitBranch: null, // current git branch (nullable)
_registered: false, // surface registered?
_active: false, // currently active?
// DOM (built once, reused across activations)
_els: null, // { header, fileTree, main, footer }
_built: false,
// File tree
_treeData: [], // flat file list from API
_expandedDirs: new Set(['']), // expanded directory paths
// Tabs + editors
_openFiles: new Map(), // path → { tab, editorWrap, editor(CM6), content, modified, language }
_activeFile: null, // path of currently active tab
_tabBarEl: null,
_editorAreaEl: null,
_welcomeEl: null,
// Split
_splitRatio: 0.6, // editor pane width ratio (0.0-1.0)
// ── Initialization ───────────────────────
/**
* Check if the current channel/project has a workspace.
* Called on channel switch and app init.
*/
async check() {
const channelId = App.currentChatId;
if (!channelId) {
this._unregister();
return;
}
try {
const ch = await API.getChannel(channelId);
const wsId = ch.workspace_id || null;
// Also check project workspace if channel doesn't have one
if (!wsId && ch.project_id) {
try {
const proj = await API.getProject(ch.project_id);
if (proj.workspace_id) {
this._register(proj.workspace_id, proj.name || 'Workspace');
return;
}
} catch (_) { /* no project workspace */ }
}
if (wsId) {
// Fetch workspace details
try {
const ws = await API.getWorkspace(wsId);
this._register(wsId, ws.name || 'Workspace');
} catch (_) {
this._register(wsId, 'Workspace');
}
} else {
this._unregister();
}
} catch (e) {
console.warn('[EditorMode] check failed:', e);
}
},
_register(wsId, name) {
this._wsId = wsId;
this._wsName = name;
if (this._registered) return;
this._registered = true;
Surfaces.register('editor', {
label: 'Editor',
icon: 'code',
regions: ['surface-header', 'surface-main', 'surface-footer', 'sidebar-content'],
activate: () => this._activate(),
deactivate: () => this._deactivate(),
});
console.log(`[EditorMode] Registered for workspace ${wsId}`);
},
_unregister() {
if (!this._registered) return;
if (this._active) {
Surfaces.activate('chat');
}
Surfaces.unregister('editor');
this._registered = false;
this._wsId = null;
this._built = false;
this._els = null;
this._openFiles.clear();
this._activeFile = null;
console.log('[EditorMode] Unregistered');
},
// ── Surface Callbacks ────────────────────
_activate() {
this._active = true;
if (!this._built) {
this._build();
}
// Populate regions
const regions = Surfaces._regionEls;
// Header
const headerEl = regions.get('surface-header');
if (headerEl) headerEl.appendChild(this._els.header);
// Sidebar → file tree
const sidebarEl = regions.get('sidebar-content');
if (sidebarEl) sidebarEl.appendChild(this._els.fileTree);
// Main → split pane (editor + chat)
const mainEl = regions.get('surface-main');
if (mainEl) mainEl.appendChild(this._els.main);
// Footer → status bar
const footerEl = regions.get('surface-footer');
if (footerEl) footerEl.appendChild(this._els.footer);
// Embed saved chat DOM into our chat pane
this._embedChat();
// Refresh file tree
this._refreshFileTree();
this._refreshGitBranch();
// Focus active editor
if (this._activeFile) {
const f = this._openFiles.get(this._activeFile);
if (f?.editor?.focus) setTimeout(() => f.editor.focus(), 50);
}
},
_deactivate() {
this._active = false;
// Return chat DOM to Surfaces saved store before regions are saved
this._returnChat();
},
// ── Chat Panel Embedding ─────────────────
// Borrow chat DOM from the saved fragments so the chat panel
// shows real messages and the user can interact with AI.
_chatPane: null,
_chatMessagesSlot: null,
_chatInputSlot: null,
_embedChat() {
if (!this._chatPane) return;
// Grab saved chat fragments
const msgFrag = Surfaces.getSavedFragment('chat', 'surface-main');
const inputFrag = Surfaces.getSavedFragment('chat', 'surface-footer');
if (msgFrag) {
this._chatMessagesSlot.appendChild(msgFrag);
// Scroll to bottom
this._chatMessagesSlot.scrollTop = this._chatMessagesSlot.scrollHeight;
}
if (inputFrag) {
this._chatInputSlot.appendChild(inputFrag);
}
},
_returnChat() {
if (!this._chatPane) return;
// Collect chat DOM back into fragments and return to Surfaces store
const msgFrag = document.createDocumentFragment();
while (this._chatMessagesSlot.firstChild) {
msgFrag.appendChild(this._chatMessagesSlot.firstChild);
}
Surfaces.putSavedFragment('chat', 'surface-main', msgFrag);
const inputFrag = document.createDocumentFragment();
while (this._chatInputSlot.firstChild) {
inputFrag.appendChild(this._chatInputSlot.firstChild);
}
Surfaces.putSavedFragment('chat', 'surface-footer', inputFrag);
},
// ── DOM Construction ─────────────────────
_build() {
this._els = {
header: this._buildHeader(),
fileTree: this._buildFileTreeContainer(),
main: this._buildMain(),
footer: this._buildFooter(),
};
this._built = true;
},
_buildHeader() {
const el = document.createElement('div');
el.className = 'editor-header';
el.innerHTML = `
<div class="editor-header-left">
<span class="editor-ws-name" id="editorWsName">${this._esc(this._wsName)}</span>
<span class="editor-branch" id="editorBranch"></span>
</div>
<div class="editor-header-right">
<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>
</button>
</div>`;
el.querySelector('#editorRefreshBtn').addEventListener('click', () => this._refreshFileTree());
return el;
},
_buildFileTreeContainer() {
const el = document.createElement('div');
el.className = 'editor-file-tree';
el.innerHTML = '<div class="editor-tree-loading">Loading files…</div>';
return el;
},
_buildMain() {
const el = document.createElement('div');
el.className = 'editor-main';
// Left: editor area (tabs + code)
const leftPane = document.createElement('div');
leftPane.className = 'editor-left-pane';
this._tabBarEl = document.createElement('div');
this._tabBarEl.className = 'editor-tab-bar';
leftPane.appendChild(this._tabBarEl);
this._editorAreaEl = document.createElement('div');
this._editorAreaEl.className = 'editor-code-area';
leftPane.appendChild(this._editorAreaEl);
// Welcome screen
this._welcomeEl = document.createElement('div');
this._welcomeEl.className = 'editor-welcome';
this._welcomeEl.innerHTML = `
<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>
<p>Select a file to start editing</p>
<p class="editor-welcome-hint"><kbd>Ctrl+P</kbd> Quick Open</p>
</div>`;
this._editorAreaEl.appendChild(this._welcomeEl);
// Resize handle
const handle = document.createElement('div');
handle.className = 'editor-split-handle';
this._initResize(handle, leftPane);
// Right: chat panel
const rightPane = document.createElement('div');
rightPane.className = 'editor-right-pane';
const chatHeader = document.createElement('div');
chatHeader.className = 'editor-chat-header';
chatHeader.innerHTML = '<span>AI Chat</span>';
rightPane.appendChild(chatHeader);
this._chatMessagesSlot = document.createElement('div');
this._chatMessagesSlot.className = 'editor-chat-messages';
rightPane.appendChild(this._chatMessagesSlot);
this._chatInputSlot = document.createElement('div');
this._chatInputSlot.className = 'editor-chat-input';
rightPane.appendChild(this._chatInputSlot);
this._chatPane = rightPane;
el.appendChild(leftPane);
el.appendChild(handle);
el.appendChild(rightPane);
// Apply initial split ratio
leftPane.style.flex = `0 0 ${this._splitRatio * 100}%`;
rightPane.style.flex = '1';
return el;
},
_buildFooter() {
const el = document.createElement('div');
el.className = 'editor-status-bar';
el.innerHTML = `
<span class="editor-status-file" id="editorStatusFile">No file open</span>
<span class="editor-status-sep">│</span>
<span class="editor-status-cursor" id="editorStatusCursor"></span>
<span class="editor-status-sep">│</span>
<span class="editor-status-lang" id="editorStatusLang"></span>
<span class="editor-status-right">
<span class="editor-status-branch" id="editorStatusBranch"></span>
</span>`;
return el;
},
// ── Resize Handle ────────────────────────
_initResize(handle, leftPane) {
let startX = 0, startWidth = 0;
const onMove = (e) => {
const dx = (e.clientX || e.touches?.[0]?.clientX || 0) - startX;
const parentWidth = handle.parentElement.clientWidth;
const newWidth = Math.max(200, Math.min(parentWidth - 200, startWidth + dx));
leftPane.style.flex = `0 0 ${newWidth}px`;
this._splitRatio = newWidth / parentWidth;
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
document.removeEventListener('touchmove', onMove);
document.removeEventListener('touchend', onUp);
document.body.style.userSelect = '';
document.body.style.cursor = '';
};
handle.addEventListener('mousedown', (e) => {
e.preventDefault();
startX = e.clientX;
startWidth = leftPane.clientWidth;
document.body.style.userSelect = 'none';
document.body.style.cursor = 'col-resize';
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
handle.addEventListener('touchstart', (e) => {
startX = e.touches[0].clientX;
startWidth = leftPane.clientWidth;
document.addEventListener('touchmove', onMove, { passive: false });
document.addEventListener('touchend', onUp);
});
},
// ── File Tree ────────────────────────────
async _refreshFileTree() {
if (!this._wsId) return;
try {
const resp = await API.listWorkspaceFiles(this._wsId, '', true);
this._treeData = resp.files || resp.data || resp || [];
this._renderFileTree();
} catch (e) {
console.error('[EditorMode] Failed to load file tree:', e);
this._els.fileTree.innerHTML = '<div class="editor-tree-error">Failed to load files</div>';
}
},
_renderFileTree() {
const container = this._els.fileTree;
container.innerHTML = '';
if (!this._treeData.length) {
container.innerHTML = '<div class="editor-tree-empty">No files</div>';
return;
}
// Build tree structure from flat list
const tree = this._buildTreeStructure(this._treeData);
this._renderTreeNodes(tree, container, 0);
},
_buildTreeStructure(files) {
// files: [{path, is_directory, size_bytes, content_type}, ...]
// Build nested tree: { name, path, isDir, children[], file }
const root = { name: '', path: '', isDir: true, children: [], file: null };
const dirs = new Map();
dirs.set('', root);
// Sort: directories first, then alphabetical
const sorted = [...files].sort((a, b) => {
if (a.is_directory !== b.is_directory) return a.is_directory ? -1 : 1;
return a.path.localeCompare(b.path);
});
for (const f of sorted) {
const parts = f.path.split('/');
const name = parts[parts.length - 1];
const parentPath = parts.slice(0, -1).join('/');
// Ensure parent directories exist
let parent = dirs.get(parentPath);
if (!parent) {
// Create implicit parent dirs
let accumulated = '';
for (let i = 0; i < parts.length - 1; i++) {
const prev = accumulated;
accumulated = accumulated ? accumulated + '/' + parts[i] : parts[i];
if (!dirs.has(accumulated)) {
const dirNode = { name: parts[i], path: accumulated, isDir: true, children: [], file: null };
dirs.set(accumulated, dirNode);
const p = dirs.get(prev);
if (p) p.children.push(dirNode);
}
}
parent = dirs.get(parentPath);
}
if (f.is_directory) {
let existing = dirs.get(f.path);
if (!existing) {
existing = { name, path: f.path, isDir: true, children: [], file: f };
dirs.set(f.path, existing);
if (parent) parent.children.push(existing);
} else {
existing.file = f;
}
} else {
const node = { name, path: f.path, isDir: false, children: [], file: f };
if (parent) parent.children.push(node);
}
}
return root.children;
},
_renderTreeNodes(nodes, container, depth) {
for (const node of nodes) {
const row = document.createElement('div');
row.className = 'editor-tree-row' + (node.path === this._activeFile ? ' active' : '');
row.style.paddingLeft = (12 + depth * 16) + 'px';
row.dataset.path = node.path;
if (node.isDir) {
const expanded = this._expandedDirs.has(node.path);
row.innerHTML = `
<span class="editor-tree-arrow ${expanded ? 'expanded' : ''}">${expanded ? '▾' : '▸'}</span>
<span class="editor-tree-icon">📁</span>
<span class="editor-tree-name">${this._esc(node.name)}</span>`;
row.addEventListener('click', () => {
if (this._expandedDirs.has(node.path)) {
this._expandedDirs.delete(node.path);
} else {
this._expandedDirs.add(node.path);
}
this._renderFileTree();
});
container.appendChild(row);
if (expanded && node.children.length) {
this._renderTreeNodes(node.children, container, depth + 1);
}
} else {
row.innerHTML = `
<span class="editor-tree-icon">${this._fileIcon(node.name)}</span>
<span class="editor-tree-name">${this._esc(node.name)}</span>`;
row.addEventListener('click', () => this._openFile(node.path));
// Context menu
row.addEventListener('contextmenu', (e) => {
e.preventDefault();
this._showFileContextMenu(e, node);
});
container.appendChild(row);
}
}
},
_fileIcon(name) {
const ext = name.split('.').pop()?.toLowerCase();
const icons = {
js: '📜', ts: '📜', jsx: '⚛', tsx: '⚛',
go: '🔵', py: '🐍', rs: '🦀', rb: '💎',
html: '🌐', css: '🎨', scss: '🎨', less: '🎨',
json: '📋', yaml: '📋', yml: '📋', toml: '📋',
md: '📝', txt: '📄', svg: '🖼',
sql: '🗃', sh: '⚙', bash: '⚙', zsh: '⚙',
dockerfile: '🐳', makefile: '🔧',
mod: '📦', sum: '📦', lock: '🔒',
};
return icons[ext] || '📄';
},
_showFileContextMenu(e, node) {
// Simple context menu using CSS positioned div
const existing = document.querySelector('.editor-ctx-menu');
if (existing) existing.remove();
const menu = document.createElement('div');
menu.className = 'editor-ctx-menu';
menu.style.left = e.clientX + 'px';
menu.style.top = e.clientY + 'px';
const items = [
{ label: 'Open', action: () => this._openFile(node.path) },
{ label: 'Delete', action: () => this._deleteFile(node.path) },
];
for (const item of items) {
const row = document.createElement('div');
row.className = 'editor-ctx-item';
row.textContent = item.label;
row.addEventListener('click', () => { menu.remove(); item.action(); });
menu.appendChild(row);
}
document.body.appendChild(menu);
const dismiss = (ev) => {
if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('click', dismiss); }
};
setTimeout(() => document.addEventListener('click', dismiss), 0);
},
// ── Tab Management ───────────────────────
async _openFile(path) {
if (this._openFiles.has(path)) {
this._activateTab(path);
return;
}
// Fetch file content
let content = '';
try {
content = await API.readWorkspaceFile(this._wsId, path);
} catch (e) {
console.error('[EditorMode] Failed to read file:', e);
if (typeof UI !== 'undefined') UI.toast(`Failed to open ${path}`, 'error');
return;
}
const lang = this._detectLanguage(path);
// Create tab
const tab = document.createElement('div');
tab.className = 'editor-tab';
tab.dataset.path = path;
tab.innerHTML = `
<span class="editor-tab-icon">${this._fileIcon(path.split('/').pop())}</span>
<span class="editor-tab-name">${this._esc(path.split('/').pop())}</span>
<span class="editor-tab-modified" style="display:none">●</span>
<button class="editor-tab-close" title="Close">✕</button>`;
tab.addEventListener('click', (e) => {
if (!e.target.closest('.editor-tab-close')) this._activateTab(path);
});
tab.querySelector('.editor-tab-close').addEventListener('click', (e) => {
e.stopPropagation();
this._closeFile(path);
});
this._tabBarEl.appendChild(tab);
// Create editor wrapper
const editorWrap = document.createElement('div');
editorWrap.className = 'editor-cm-wrap';
editorWrap.style.display = 'none';
this._editorAreaEl.appendChild(editorWrap);
// Create CM6 editor or textarea fallback
let editor = null;
if (window.CM?.codeEditor) {
editor = CM.codeEditor(editorWrap, {
language: lang,
value: content,
lineNumbers: true,
});
// Listen for changes to mark modified
if (editor.onUpdate) {
editor.onUpdate(() => this._markModified(path));
}
} else {
// Textarea fallback
const ta = document.createElement('textarea');
ta.className = 'editor-textarea-fallback';
ta.value = content;
ta.addEventListener('input', () => this._markModified(path));
editorWrap.appendChild(ta);
editor = {
getValue: () => ta.value,
setValue: (v) => { ta.value = v; },
focus: () => ta.focus(),
destroy: () => {},
_textarea: true,
};
}
this._openFiles.set(path, {
tab, editorWrap, editor, content, modified: false, language: lang,
});
this._activateTab(path);
},
_activateTab(path) {
if (this._activeFile === path) return;
// Deactivate previous
if (this._activeFile && this._openFiles.has(this._activeFile)) {
const prev = this._openFiles.get(this._activeFile);
prev.tab.classList.remove('active');
prev.editorWrap.style.display = 'none';
}
this._activeFile = path;
const f = this._openFiles.get(path);
if (!f) return;
f.tab.classList.add('active');
f.editorWrap.style.display = '';
this._welcomeEl.style.display = 'none';
// Update status bar
this._updateStatusBar();
// Update file tree active state
this._els.fileTree.querySelectorAll('.editor-tree-row').forEach(row => {
row.classList.toggle('active', row.dataset.path === path);
});
// Focus editor
if (f.editor?.focus) setTimeout(() => f.editor.focus(), 10);
},
async _closeFile(path) {
const f = this._openFiles.get(path);
if (!f) return;
// Confirm if modified
if (f.modified) {
const save = await this._confirmClose(path);
if (save === null) return; // cancelled
if (save) await this._saveFile(path);
}
// Clean up
f.tab.remove();
f.editorWrap.remove();
if (f.editor?.destroy) f.editor.destroy();
this._openFiles.delete(path);
// Activate next tab
if (this._activeFile === path) {
this._activeFile = null;
const remaining = Array.from(this._openFiles.keys());
if (remaining.length) {
this._activateTab(remaining[remaining.length - 1]);
} else {
this._welcomeEl.style.display = '';
this._updateStatusBar();
}
}
},
async _confirmClose(path) {
if (typeof showConfirm === 'function') {
const r = await showConfirm(`Save changes to ${path.split('/').pop()}?`, {
confirmLabel: 'Save', cancelLabel: 'Discard', showCancel: true
});
return r; // true = save, false = discard
}
return window.confirm(`Save changes to ${path.split('/').pop()}?`);
},
_markModified(path) {
const f = this._openFiles.get(path);
if (!f || f.modified) return;
const currentVal = f.editor?.getValue?.() ?? '';
if (currentVal === f.content) return;
f.modified = true;
f.tab.querySelector('.editor-tab-modified').style.display = '';
f.tab.classList.add('modified');
},
// ── Save ─────────────────────────────────
async _saveFile(path) {
const f = this._openFiles.get(path);
if (!f) return;
const content = f.editor?.getValue?.() ?? '';
try {
await API.writeWorkspaceFile(this._wsId, path, content);
f.content = content;
f.modified = false;
f.tab.querySelector('.editor-tab-modified').style.display = 'none';
f.tab.classList.remove('modified');
if (typeof UI !== 'undefined') UI.toast(`Saved ${path.split('/').pop()}`, 'success');
} catch (e) {
console.error('[EditorMode] Save failed:', e);
if (typeof UI !== 'undefined') UI.toast(`Failed to save: ${e.message}`, 'error');
}
},
async _saveActiveFile() {
if (this._activeFile) await this._saveFile(this._activeFile);
},
async _deleteFile(path) {
const ok = typeof showConfirm === 'function'
? await showConfirm(`Delete ${path}?`)
: window.confirm(`Delete ${path}?`);
if (!ok) return;
try {
await API.deleteWorkspaceFile(this._wsId, path);
// Close tab if open
if (this._openFiles.has(path)) {
const f = this._openFiles.get(path);
f.modified = false; // skip save prompt
await this._closeFile(path);
}
this._refreshFileTree();
} catch (e) {
if (typeof UI !== 'undefined') UI.toast(`Delete failed: ${e.message}`, 'error');
}
},
// ── Language Detection ────────────────────
_detectLanguage(path) {
const ext = path.split('.').pop()?.toLowerCase();
const map = {
js: 'javascript', mjs: 'javascript', cjs: 'javascript',
ts: 'typescript', tsx: 'typescript', jsx: 'javascript',
go: 'go', py: 'python', rs: 'rust', rb: 'ruby',
html: 'html', htm: 'html', css: 'css', scss: 'css', less: 'css',
json: 'json', yaml: 'yaml', yml: 'yaml', toml: 'toml',
md: 'markdown', mdx: 'markdown',
sql: 'sql', sh: 'shell', bash: 'shell', zsh: 'shell',
xml: 'xml', svg: 'xml',
c: 'cpp', cpp: 'cpp', h: 'cpp', hpp: 'cpp',
java: 'java', kt: 'java',
php: 'php', pl: 'perl',
dockerfile: 'dockerfile',
};
return map[ext] || 'text';
},
// ── Git ──────────────────────────────────
async _refreshGitBranch() {
if (!this._wsId) return;
try {
const resp = await API.getWorkspaceGitBranches(this._wsId);
this._gitBranch = resp.current || null;
this._updateBranchDisplay();
} catch (_) {
this._gitBranch = null;
this._updateBranchDisplay();
}
},
_updateBranchDisplay() {
const headerEl = this._els?.header?.querySelector('#editorBranch');
const statusEl = this._els?.footer?.querySelector('#editorStatusBranch');
const branchText = this._gitBranch ? `${this._gitBranch}` : '';
if (headerEl) headerEl.textContent = branchText;
if (statusEl) statusEl.textContent = branchText;
},
// ── Status Bar ───────────────────────────
_updateStatusBar() {
if (!this._els?.footer) return;
const fileEl = this._els.footer.querySelector('#editorStatusFile');
const langEl = this._els.footer.querySelector('#editorStatusLang');
const cursorEl = this._els.footer.querySelector('#editorStatusCursor');
if (this._activeFile) {
const f = this._openFiles.get(this._activeFile);
if (fileEl) fileEl.textContent = this._activeFile;
if (langEl) langEl.textContent = f?.language || '';
if (cursorEl) cursorEl.textContent = '';
} else {
if (fileEl) fileEl.textContent = 'No file open';
if (langEl) langEl.textContent = '';
if (cursorEl) cursorEl.textContent = '';
}
},
// ── Quick Open (Ctrl+P) ──────────────────
_quickOpenEl: null,
_showQuickOpen() {
if (this._quickOpenEl) { this._quickOpenEl.remove(); this._quickOpenEl = null; return; }
const overlay = document.createElement('div');
overlay.className = 'editor-quick-open';
overlay.innerHTML = `
<div class="editor-qo-dialog">
<input class="editor-qo-input" type="text" placeholder="Open file…" autocomplete="off">
<div class="editor-qo-results"></div>
</div>`;
const input = overlay.querySelector('.editor-qo-input');
const results = overlay.querySelector('.editor-qo-results');
const allFiles = this._treeData.filter(f => !f.is_directory);
const render = (query) => {
results.innerHTML = '';
const q = query.toLowerCase();
const matches = q
? allFiles.filter(f => f.path.toLowerCase().includes(q)).slice(0, 20)
: allFiles.slice(0, 20);
for (const f of matches) {
const row = document.createElement('div');
row.className = 'editor-qo-row';
row.innerHTML = `<span class="editor-qo-icon">${this._fileIcon(f.path.split('/').pop())}</span> ${this._esc(f.path)}`;
row.addEventListener('click', () => {
overlay.remove();
this._quickOpenEl = null;
this._openFile(f.path);
});
results.appendChild(row);
}
};
input.addEventListener('input', () => render(input.value));
input.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { overlay.remove(); this._quickOpenEl = null; }
if (e.key === 'Enter') {
const first = results.querySelector('.editor-qo-row');
if (first) first.click();
}
});
overlay.addEventListener('click', (e) => {
if (e.target === overlay) { overlay.remove(); this._quickOpenEl = null; }
});
document.body.appendChild(overlay);
this._quickOpenEl = overlay;
render('');
input.focus();
},
// ── Keyboard Shortcuts ───────────────────
_onKeyDown(e) {
if (!EditorMode._active) return;
// Ctrl/Cmd+S — save
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
EditorMode._saveActiveFile();
return;
}
// Ctrl/Cmd+P — quick open
if ((e.ctrlKey || e.metaKey) && e.key === 'p') {
e.preventDefault();
EditorMode._showQuickOpen();
return;
}
// Ctrl/Cmd+W — close tab
if ((e.ctrlKey || e.metaKey) && e.key === 'w') {
if (EditorMode._activeFile) {
e.preventDefault();
EditorMode._closeFile(EditorMode._activeFile);
}
}
},
// ── Live Update on workspace.file.changed ─
_onFileChanged(ev) {
if (!EditorMode._active) return;
const { path } = ev || {};
if (!path) return;
// If the file is open, refresh its content
const f = EditorMode._openFiles.get(path);
if (f && !f.modified) {
API.readWorkspaceFile(EditorMode._wsId, path).then(content => {
if (f.editor?.setValue) f.editor.setValue(content);
f.content = content;
}).catch(() => {});
}
// Also refresh file tree
EditorMode._refreshFileTree();
},
// ── Helpers ──────────────────────────────
_esc(s) {
const d = document.createElement('div');
d.textContent = s || '';
return d.innerHTML;
},
// ── Global Init ──────────────────────────
init() {
// Keyboard shortcuts
document.addEventListener('keydown', this._onKeyDown);
// Listen for channel switches to check workspace availability
Events.on('chat.switched', () => this.check());
Events.on('chat.created', () => this.check());
// Listen for workspace file changes (from AI tool calls)
Events.on('workspace.file.changed', this._onFileChanged);
// Initial check
setTimeout(() => this.check(), 500);
console.log('[EditorMode] Initialized');
},
};
// Auto-init after DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => EditorMode.init());
} else {
EditorMode.init();
}

View File

@@ -240,6 +240,12 @@ function showChatContextMenu(e, chatId) {
<span class="ctx-icon">+</span> New project… <span class="ctx-icon">+</span> New project…
</button>`; </button>`;
// Workspace override (v0.21.5)
items += '<div class="ctx-divider"></div>';
items += `<button class="ctx-item" onclick="showWorkspacePicker('${chatId}');dismissChatContextMenu()">
<span class="ctx-icon">📁</span> Set workspace…
</button>`;
menu.innerHTML = items; menu.innerHTML = items;
// Position near cursor // Position near cursor
@@ -392,6 +398,16 @@ function _registerProjectPanel() {
<label class="project-panel-label">Notes</label> <label class="project-panel-label">Notes</label>
<div id="projectNoteList" class="project-panel-list"></div> <div id="projectNoteList" class="project-panel-list"></div>
</div> </div>
<div class="project-panel-section">
<label class="project-panel-label">Workspace</label>
<div class="project-panel-ws-row" id="projectWsRow">
<select id="projectWsSelect" class="project-panel-select">
<option value="">None</option>
</select>
<button class="btn-small" id="projectWsCreate" title="Create workspace">+ New</button>
</div>
<div class="project-panel-hint">Workspace files are available to AI in all project chats. Enables the Editor surface.</div>
</div>
<div class="project-panel-section project-panel-footer"> <div class="project-panel-section project-panel-footer">
<label class="project-panel-checkbox"> <label class="project-panel-checkbox">
<input type="checkbox" id="projectArchiveToggle"> <input type="checkbox" id="projectArchiveToggle">
@@ -413,6 +429,10 @@ function _registerProjectPanel() {
// Wire archive toggle (v0.19.2) // Wire archive toggle (v0.19.2)
el.querySelector('#projectArchiveToggle').addEventListener('change', _toggleProjectArchive); el.querySelector('#projectArchiveToggle').addEventListener('change', _toggleProjectArchive);
// Wire workspace select + create (v0.21.5)
el.querySelector('#projectWsSelect').addEventListener('change', _saveProjectWorkspace);
el.querySelector('#projectWsCreate').addEventListener('click', _createProjectWorkspace);
PanelRegistry.register('project', { PanelRegistry.register('project', {
element: el, element: el,
label: 'Project', label: 'Project',
@@ -457,6 +477,9 @@ async function _loadProjectPanel(projectId) {
// Load notes // Load notes
await _renderProjectNotes(projectId); await _renderProjectNotes(projectId);
// Load workspace picker (v0.21.5)
await _renderProjectWorkspace(projectId);
} }
async function _saveProjectPrompt() { async function _saveProjectPrompt() {
@@ -643,6 +666,143 @@ async function _toggleProjectArchive() {
} }
} }
// ── Workspace picker (v0.21.5) ───────────────
async function _renderProjectWorkspace(projectId) {
const sel = document.getElementById('projectWsSelect');
if (!sel) return;
// Fetch current project state + available workspaces in parallel
const [proj, workspaces] = await Promise.all([
API.getProject(projectId).catch(() => null),
API.listWorkspaces().catch(() => []),
]);
const currentWsId = proj?.workspace_id || '';
const wsList = Array.isArray(workspaces) ? workspaces : (workspaces?.data || []);
sel.innerHTML = '<option value="">None</option>';
for (const ws of wsList) {
const opt = document.createElement('option');
opt.value = ws.id;
opt.textContent = ws.name || ws.id.slice(0, 8);
if (ws.id === currentWsId) opt.selected = true;
sel.appendChild(opt);
}
}
async function _saveProjectWorkspace() {
if (!_projectPanelId) return;
const wsId = document.getElementById('projectWsSelect').value;
try {
await API.updateProject(_projectPanelId, { workspace_id: wsId || null });
UI.toast(wsId ? 'Workspace linked' : 'Workspace unlinked', 'success');
// Trigger editor surface check
if (typeof EditorMode !== 'undefined') EditorMode.check();
} catch (e) {
UI.toast('Failed to update workspace', 'error');
}
}
async function _createProjectWorkspace() {
if (!_projectPanelId) return;
const proj = App.projects.find(p => p.id === _projectPanelId);
const name = prompt('Workspace name:', (proj?.name || 'Project') + ' Workspace');
if (!name || !name.trim()) return;
try {
const ws = await API.createWorkspace({
name: name.trim(),
owner_type: 'user',
owner_id: API.user?.id || '',
});
// Auto-bind to project
await API.updateProject(_projectPanelId, { workspace_id: ws.id });
UI.toast('Workspace created and linked', 'success');
// Refresh the picker
await _renderProjectWorkspace(_projectPanelId);
// Trigger editor surface check
if (typeof EditorMode !== 'undefined') EditorMode.check();
} catch (e) {
UI.toast('Failed to create workspace: ' + (e.message || e), 'error');
}
}
// ── Channel workspace picker (v0.21.5) ───────
async function showWorkspacePicker(chatId) {
let workspaces = [];
try {
const resp = await API.listWorkspaces();
workspaces = Array.isArray(resp) ? resp : (resp?.data || []);
} catch (_) {}
// Get current channel workspace
let currentWsId = '';
try {
const ch = await API.getChannel(chatId);
currentWsId = ch.workspace_id || '';
} catch (_) {}
// Build options
const options = [{ value: '', label: 'None (inherit from project)' }];
for (const ws of workspaces) {
options.push({ value: ws.id, label: ws.name || ws.id.slice(0, 8) });
}
options.push({ value: '__new__', label: '+ Create new workspace…' });
// Use simple select via popup
const overlay = document.createElement('div');
overlay.className = 'editor-quick-open'; // reuse overlay style
overlay.innerHTML = `
<div class="editor-qo-dialog" style="width:340px">
<div style="padding:10px 14px; font-weight:600; border-bottom:1px solid var(--border)">Set Workspace</div>
<div class="editor-qo-results" id="wsPickerList"></div>
</div>`;
const list = overlay.querySelector('#wsPickerList');
for (const opt of options) {
const row = document.createElement('div');
row.className = 'editor-qo-row' + (opt.value === currentWsId ? ' active' : '');
row.style.fontWeight = opt.value === currentWsId ? '600' : '';
row.textContent = opt.label;
row.addEventListener('click', async () => {
overlay.remove();
if (opt.value === '__new__') {
const name = prompt('Workspace name:');
if (!name || !name.trim()) return;
try {
const ws = await API.createWorkspace({
name: name.trim(),
owner_type: 'user',
owner_id: API.user?.id || '',
});
await API.updateChannel(chatId, { workspace_id: ws.id });
UI.toast('Workspace created and linked', 'success');
if (typeof EditorMode !== 'undefined') EditorMode.check();
} catch (e) {
UI.toast('Failed: ' + (e.message || e), 'error');
}
} else {
try {
await API.updateChannel(chatId, { workspace_id: opt.value || '' });
UI.toast(opt.value ? 'Workspace linked' : 'Workspace unlinked', 'success');
if (typeof EditorMode !== 'undefined') EditorMode.check();
} catch (e) {
UI.toast('Failed: ' + (e.message || e), 'error');
}
}
});
list.appendChild(row);
}
overlay.addEventListener('click', (e) => {
if (e.target === overlay) overlay.remove();
});
document.body.appendChild(overlay);
}
// ═══════════════════════════════════════════ // ═══════════════════════════════════════════
// CHANNEL REORDER WITHIN PROJECT (v0.19.2) // CHANNEL REORDER WITHIN PROJECT (v0.19.2)
// ═══════════════════════════════════════════ // ═══════════════════════════════════════════

View File

@@ -167,6 +167,28 @@ const Surfaces = {
return this._registry.size > 1; return this._registry.size > 1;
}, },
/**
* Get a saved DocumentFragment for a specific surface + region.
* Used by surfaces like editor-mode that want to embed chat DOM
* inside their own layout.
* @param {string} surfaceId — the surface that owns the saved DOM
* @param {string} regionId — the region name
* @returns {DocumentFragment|null}
*/
getSavedFragment(surfaceId, regionId) {
const key = `${surfaceId}::${regionId}`;
return this._saved.get(key) || null;
},
/**
* Put a DocumentFragment back into the saved store.
* Used during deactivation to return borrowed DOM.
*/
putSavedFragment(surfaceId, regionId, frag) {
const key = `${surfaceId}::${regionId}`;
this._saved.set(key, frag);
},
// ── Region Management ──────────────────── // ── Region Management ────────────────────
/** /**