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 @@
+