diff --git a/FORKING_IMPL.md b/FORKING_IMPL.md new file mode 100644 index 0000000..5de14f3 --- /dev/null +++ b/FORKING_IMPL.md @@ -0,0 +1,96 @@ +# Message Editing & Forking — Implementation + +**Version:** 0.7.x (Phase 2 per ARCHITECTURE.md §8) +**Depends on:** parent_id (006), channel_cursors (008) — both deployed + +--- + +## Status: Complete (backend + frontend) + +### Backend (already existed) +- [x] Migration 013 (`deleted_at`, `sibling_index`) +- [x] `tree.go` — `getActivePath`, `getActiveLeaf`, `getPathToLeaf`, `getSiblings`, + `findLeafFromMessage`, `nextSiblingIndex`, `updateCursor` +- [x] `completion.go` — `loadConversation` walks active path via recursive CTE, + `persistMessage` is cursor-aware with sibling_index, returns new message ID +- [x] `messages.go` — `GetActivePath`, `EditMessage`, `Regenerate` (streaming + sibling), + `UpdateCursor`, `ListSiblings` +- [x] Routes wired in `main.go` + +### Frontend (new work) +- [x] `api.js` — `getActivePath`, `editMessage`, `streamRegenerate`, `updateCursor`, `listSiblings` +- [x] `app.js` — tree-aware message flow: + - `selectChat` uses `/path` endpoint + - `sendMessage` reloads path after streaming for server-authoritative IDs + - `regenerateMessage(id)` creates sibling via message-specific endpoint + - `editMessage(id)` → inline edit → `submitEdit` → sibling + completion + - `switchSibling(id, direction)` navigates branches via cursor update + - `reloadActivePath()` refreshes from server after every mutation + - Legacy `regenerate()` button delegates to `regenerateMessage(lastAssistantId)` +- [x] `ui.js`: + - `_messageHTML` renders ‹ 1/3 › branch indicators at fork points + - Edit button on user messages, Regen button on assistant messages + - `showEditInline` — textarea with Ctrl+Enter submit, Escape cancel +- [x] `styles.css` — `.branch-nav`, `.branch-arrow`, `.branch-pos`, + `.msg-edit-input`, `.msg-edit-actions`, `.msg-edit-cancel`, `.msg-edit-submit` + +--- + +## API Endpoints + +| Method | Path | Purpose | +|--------|------|---------| +| `GET` | `/channels/:id/path` | Active path with sibling metadata | +| `POST` | `/channels/:id/messages/:msgId/edit` | Create sibling user message | +| `POST` | `/channels/:id/messages/:msgId/regenerate` | Create sibling assistant response (SSE) | +| `PUT` | `/channels/:id/cursor` | Switch active branch | +| `GET` | `/channels/:id/messages/:msgId/siblings` | List siblings at a fork point | + +--- + +## Design Decisions + +1. **`sibling_index` column** — explicit integer, not derived from `created_at`. Tree is self-describing. +2. **New `/path` endpoint** — `ListMessages` retained for admin/debug. Frontend uses `/path` exclusively. +3. **Two-step edit** — API: edit creates sibling, completion is separate. Frontend chains them. +4. **Uniform ‹ › arrows** — same UI regardless of how fork was created (edit, regen, explicit). +5. **Message-specific regenerate** — replaces old channel-level stub. Bottom-bar button delegates. +6. **Branch switch → leaf** — clicking mid-tree sibling walks to deepest descendant via `findLeafFromMessage`. +7. **Path reload after every mutation** — `reloadActivePath()` called post-send, edit, regen, abort. + +--- + +## User Flows + +### Edit a message +1. Hover user message → click **Edit** +2. Textarea replaces message text (Ctrl+Enter to submit, Escape to cancel) +3. Submit → `POST /messages/:id/edit` (creates sibling) → `POST /chat/completions` (new response) +4. Branch indicator appears: ‹ 1/2 › +5. Old branch preserved, navigable via arrows + +### Regenerate a response +1. Hover assistant message → click **Regen** (or bottom-bar ↻ button) +2. `POST /messages/:id/regenerate` streams new response as sibling +3. Branch indicator appears: ‹ 1/2 › +4. Original response preserved, navigable via arrows + +### Navigate branches +1. Click ‹ or › on any fork point +2. `GET /messages/:id/siblings` → pick adjacent → `PUT /cursor` with sibling ID +3. Backend walks to leaf, returns new path +4. Entire conversation below fork point updates to show selected branch + +--- + +## Testing Checklist + +- [ ] Existing linear conversations load and render correctly (backward compat) +- [ ] New messages in linear conversation work as before +- [ ] Regen creates sibling, shows ‹ 1/2 ›, original preserved +- [ ] Multiple regens show ‹ 1/3 ›, all navigable +- [ ] Edit creates sibling user message + triggers new completion +- [ ] Branch arrows navigate correctly (← older, → newer) +- [ ] Context sent to LLM is active path only (no cross-branch pollution) +- [ ] Cursor persists across page reload (reopening chat shows last-viewed branch) +- [ ] Mobile: edit textarea usable, branch arrows tappable diff --git a/VERSION b/VERSION index faef31a..39e898a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.7.0 +0.7.1 diff --git a/server/database/migrations/013_message_forking.sql b/server/database/migrations/013_message_forking.sql new file mode 100644 index 0000000..eeb403f --- /dev/null +++ b/server/database/migrations/013_message_forking.sql @@ -0,0 +1,28 @@ +-- ========================================== +-- Migration 013: Message Forking Support +-- ========================================== +-- Adds soft delete and sibling ordering to support +-- conversation forking (edit, regenerate, branch). +-- See ARCHITECTURE.md §8 for the full tree model. +-- ========================================== + +-- Soft delete: pruned branches keep their structure for undo +ALTER TABLE messages ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; + +-- Sibling ordering: explicit position among children of same parent +-- First child = 0, second = 1, etc. Set at insert time. +ALTER TABLE messages ADD COLUMN IF NOT EXISTS sibling_index INTEGER DEFAULT 0; + +-- Partial index: fast lookup of live messages only +CREATE INDEX IF NOT EXISTS idx_messages_alive + ON messages(channel_id, created_at) + WHERE deleted_at IS NULL; + +-- Children of a parent (for sibling queries) +CREATE INDEX IF NOT EXISTS idx_messages_parent_alive + ON messages(parent_id, sibling_index) + WHERE deleted_at IS NULL; + +-- Backfill sibling_index for existing messages. +-- In linear conversations every message is the sole child of its parent, +-- so all get sibling_index = 0 (already the default). No update needed. diff --git a/server/handlers/channels_test.go b/server/handlers/channels_test.go index 3b07201..eb4cba1 100644 --- a/server/handlers/channels_test.go +++ b/server/handlers/channels_test.go @@ -128,20 +128,13 @@ func TestCreateMessageValidRoles(t *testing.T) { t.Skip("requires database connection for ownership check") } -// ── Regenerate Stub ───────────────────────── +// ── Regenerate ─────────────────────────────── -func TestRegenerateReturns501(t *testing.T) { - h := NewMessageHandler() - - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Request = httptest.NewRequest("POST", "/api/v1/channels/x/regenerate", nil) - - h.Regenerate(c) - - if w.Code != http.StatusNotImplemented { - t.Errorf("Expected 501, got %d", w.Code) - } +func TestRegenerateRequiresDB(t *testing.T) { + // Regenerate is now a full handler (creates sibling assistant message + // via tree-aware streaming). It calls userOwnsChannel → database.DB, + // so it cannot run without a live database connection. + t.Skip("requires database connection for ownership check") } // ── Pagination Helpers ────────────────────── diff --git a/server/handlers/completion.go b/server/handlers/completion.go index 9ffa617..6d7ab6b 100644 --- a/server/handlers/completion.go +++ b/server/handlers/completion.go @@ -113,7 +113,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) { } // Load conversation history - messages, err := h.loadConversation(channelID, presetSystemPrompt) + messages, err := h.loadConversation(channelID, userID, presetSystemPrompt) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load conversation"}) return @@ -126,7 +126,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) { }) // Persist user message - if err := h.persistMessage(channelID, "user", req.Content, "", 0, 0); err != nil { + if _, err := h.persistMessage(channelID, userID, "user", req.Content, "", 0, 0, nil); err != nil { log.Printf("Failed to persist user message: %v", err) } @@ -160,9 +160,9 @@ func (h *CompletionHandler) Complete(c *gin.Context) { } if stream { - h.streamCompletion(c, provider, providerCfg, provReq, channelID, model) + h.streamCompletion(c, provider, providerCfg, provReq, channelID, userID, model) } else { - h.syncCompletion(c, provider, providerCfg, provReq, channelID, model) + h.syncCompletion(c, provider, providerCfg, provReq, channelID, userID, model) } } @@ -173,7 +173,7 @@ func (h *CompletionHandler) streamCompletion( provider providers.Provider, cfg providers.ProviderConfig, req providers.CompletionRequest, - channelID, model string, + channelID, userID, model string, ) { ch, err := provider.StreamCompletion(c.Request.Context(), cfg, req) if err != nil { @@ -228,7 +228,7 @@ func (h *CompletionHandler) streamCompletion( // Persist assistant response if fullContent != "" { - if err := h.persistMessage(channelID, "assistant", fullContent, model, 0, 0); err != nil { + if _, err := h.persistMessage(channelID, userID, "assistant", fullContent, model, 0, 0, nil); err != nil { log.Printf("Failed to persist assistant message: %v", err) } } @@ -241,7 +241,7 @@ func (h *CompletionHandler) syncCompletion( provider providers.Provider, cfg providers.ProviderConfig, req providers.CompletionRequest, - channelID, model string, + channelID, userID, model string, ) { resp, err := provider.ChatCompletion(c.Request.Context(), cfg, req) if err != nil { @@ -250,7 +250,7 @@ func (h *CompletionHandler) syncCompletion( } // Persist assistant response - if err := h.persistMessage(channelID, "assistant", resp.Content, model, resp.InputTokens, resp.OutputTokens); err != nil { + if _, err := h.persistMessage(channelID, userID, "assistant", resp.Content, model, resp.InputTokens, resp.OutputTokens, nil); err != nil { log.Printf("Failed to persist assistant message: %v", err) } @@ -394,7 +394,10 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c // ── Conversation Loader ───────────────────── -func (h *CompletionHandler) loadConversation(channelID string, presetSystemPrompt string) ([]providers.Message, error) { +// loadConversation builds the message history for the LLM by walking the +// active path through the message tree. Only the current branch is sent — +// sibling branches are never included in context. +func (h *CompletionHandler) loadConversation(channelID, userID, presetSystemPrompt string) ([]providers.Message, error) { // Load system prompt from channel var systemPrompt *string _ = database.DB.QueryRow( @@ -416,23 +419,20 @@ func (h *CompletionHandler) loadConversation(channelID string, presetSystemPromp }) } - // Load message history (oldest first) - rows, err := database.DB.Query(` - SELECT role, content FROM messages - WHERE channel_id = $1 - ORDER BY created_at ASC - `, channelID) + // Walk the active path (root → leaf) instead of loading all messages + path, err := getActivePath(channelID, userID) if err != nil { return nil, err } - defer rows.Close() - for rows.Next() { - var msg providers.Message - if err := rows.Scan(&msg.Role, &msg.Content); err != nil { - return nil, err + for _, m := range path { + if m.Role == "system" { + continue // system prompts handled above } - messages = append(messages, msg) + messages = append(messages, providers.Message{ + Role: m.Role, + Content: m.Content, + }) } return messages, nil @@ -440,42 +440,59 @@ func (h *CompletionHandler) loadConversation(channelID string, presetSystemPromp // ── Message Persistence ───────────────────── -func (h *CompletionHandler) persistMessage(channelID, role, content, model string, inputTokens, outputTokens int) error { +// persistMessage inserts a message into the tree, updates the cursor, and +// returns the new message's ID. +// +// Parent resolution: +// - parentOverride (explicit parent for edit/regen operations) +// - cursor's active_leaf_id (normal conversation flow) +// - latest message by created_at (fallback for legacy/missing cursor) +func (h *CompletionHandler) persistMessage(channelID, userID, role, content, model string, inputTokens, outputTokens int, parentOverride *string) (string, error) { var tokensUsed *int if inputTokens > 0 || outputTokens > 0 { total := inputTokens + outputTokens tokensUsed = &total } - // Find current leaf for parent_id + // Determine parent var parentID *string - _ = database.DB.QueryRow( - `SELECT id FROM messages WHERE channel_id = $1 ORDER BY created_at DESC LIMIT 1`, - channelID, - ).Scan(&parentID) + if parentOverride != nil { + parentID = parentOverride + } else { + parentID, _ = getActiveLeaf(channelID, userID) + } + // Compute sibling_index + siblingIdx := nextSiblingIndex(channelID, parentID) + + // Determine participant participantType := "user" - participantID := channelID // will be overridden below + participantID := userID if role == "assistant" { participantType = "model" participantID = model - } else { - // Get channel owner as participant_id for user messages - var ownerID string - if err := database.DB.QueryRow(`SELECT user_id FROM channels WHERE id = $1`, channelID).Scan(&ownerID); err == nil { - participantID = ownerID - } } - _, err := database.DB.Exec(` - INSERT INTO messages (channel_id, role, content, model, tokens_used, parent_id, participant_type, participant_id) - VALUES ($1, $2, $3, NULLIF($4, ''), $5, $6, $7, $8) - `, channelID, role, content, model, tokensUsed, parentID, participantType, participantID) + // Insert with RETURNING id + var newID string + err := database.DB.QueryRow(` + INSERT INTO messages (channel_id, role, content, model, tokens_used, + parent_id, participant_type, participant_id, sibling_index) + VALUES ($1, $2, $3, NULLIF($4, ''), $5, $6, $7, $8, $9) + RETURNING id + `, channelID, role, content, model, tokensUsed, + parentID, participantType, participantID, siblingIdx, + ).Scan(&newID) if err != nil { - return err + return "", err + } + + // Update cursor to point at new message + if userID != "" { + _ = updateCursor(channelID, userID, newID) } // Touch channel updated_at _, _ = database.DB.Exec(`UPDATE channels SET updated_at = NOW() WHERE id = $1`, channelID) - return nil + return newID, nil } diff --git a/server/handlers/messages.go b/server/handlers/messages.go index ec3a63d..f5b47b5 100644 --- a/server/handlers/messages.go +++ b/server/handlers/messages.go @@ -2,12 +2,17 @@ package handlers import ( "database/sql" + "encoding/json" + "fmt" + "io" + "log" "math" "net/http" "github.com/gin-gonic/gin" "git.gobha.me/xcaliber/chat-switchboard/database" + "git.gobha.me/xcaliber/chat-switchboard/providers" ) // ── Request / Response types ──────────────── @@ -19,14 +24,32 @@ type createMessageRequest struct { } type messageResponse struct { - ID string `json:"id"` - ChannelID string `json:"channel_id"` - Role string `json:"role"` - Content string `json:"content"` - Model *string `json:"model"` - TokensUsed *int `json:"tokens_used"` - ParentID *string `json:"parent_id,omitempty"` - CreatedAt string `json:"created_at"` + ID string `json:"id"` + ChannelID string `json:"channel_id"` + Role string `json:"role"` + Content string `json:"content"` + Model *string `json:"model"` + TokensUsed *int `json:"tokens_used"` + ParentID *string `json:"parent_id,omitempty"` + SiblingCount int `json:"sibling_count"` + SiblingIndex int `json:"sibling_index"` + CreatedAt string `json:"created_at"` +} + +type editRequest struct { + Content string `json:"content" binding:"required"` +} + +type regenerateRequest struct { + Model string `json:"model,omitempty"` + PresetID string `json:"preset_id,omitempty"` + APIConfigID string `json:"api_config_id,omitempty"` + MaxTokens int `json:"max_tokens,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` +} + +type cursorRequest struct { + ActiveLeafID string `json:"active_leaf_id" binding:"required"` } // MessageHandler holds dependencies for message endpoints. @@ -37,7 +60,11 @@ func NewMessageHandler() *MessageHandler { return &MessageHandler{} } -// ── List Messages ─────────────────────────── +// ── List Messages (flat, all branches) ────── +// GET /channels/:id/messages +// +// Returns all live messages across all branches (useful for admin/debug). +// Frontend should prefer /channels/:id/path for rendering. func (h *MessageHandler) ListMessages(c *gin.Context) { page, perPage, offset := parsePagination(c) @@ -45,15 +72,13 @@ func (h *MessageHandler) ListMessages(c *gin.Context) { userID := getUserID(c) channelID := c.Param("id") - // Verify ownership if !userOwnsChannel(c, channelID, userID) { return } - // Count total messages var total int err := database.DB.QueryRow( - `SELECT COUNT(*) FROM messages WHERE channel_id = $1`, + `SELECT COUNT(*) FROM messages WHERE channel_id = $1 AND deleted_at IS NULL`, channelID, ).Scan(&total) if err != nil { @@ -61,11 +86,11 @@ func (h *MessageHandler) ListMessages(c *gin.Context) { return } - // Fetch messages (oldest first for conversation display) rows, err := database.DB.Query(` - SELECT id, channel_id, role, content, model, tokens_used, parent_id, created_at + SELECT id, channel_id, role, content, model, tokens_used, parent_id, + sibling_index, created_at FROM messages - WHERE channel_id = $1 + WHERE channel_id = $1 AND deleted_at IS NULL ORDER BY created_at ASC LIMIT $2 OFFSET $3 `, channelID, perPage, offset) @@ -80,12 +105,14 @@ func (h *MessageHandler) ListMessages(c *gin.Context) { var msg messageResponse err := rows.Scan( &msg.ID, &msg.ChannelID, &msg.Role, &msg.Content, - &msg.Model, &msg.TokensUsed, &msg.ParentID, &msg.CreatedAt, + &msg.Model, &msg.TokensUsed, &msg.ParentID, + &msg.SiblingIndex, &msg.CreatedAt, ) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to scan message"}) return } + msg.SiblingCount = getSiblingCount(channelID, msg.ParentID) messages = append(messages, msg) } @@ -98,7 +125,32 @@ func (h *MessageHandler) ListMessages(c *gin.Context) { }) } -// ── Create Message ────────────────────────── +// ── Active Path ───────────────────────────── +// GET /channels/:id/path +// +// Returns the active branch from root → leaf with sibling metadata +// at each node. This is what the frontend renders. + +func (h *MessageHandler) GetActivePath(c *gin.Context) { + userID := getUserID(c) + channelID := c.Param("id") + + if !userOwnsChannel(c, channelID, userID) { + return + } + + path, err := getActivePath(channelID, userID) + if err != nil { + log.Printf("GetActivePath error: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load path"}) + return + } + + c.JSON(http.StatusOK, gin.H{"path": path}) +} + +// ── Create Message (manual) ───────────────── +// POST /channels/:id/messages func (h *MessageHandler) CreateMessage(c *gin.Context) { var req createMessageRequest @@ -110,64 +162,457 @@ func (h *MessageHandler) CreateMessage(c *gin.Context) { userID := getUserID(c) channelID := c.Param("id") - // Verify ownership if !userOwnsChannel(c, channelID, userID) { return } - // Find the current leaf message to set as parent - var parentID *string - _ = database.DB.QueryRow( - `SELECT id FROM messages WHERE channel_id = $1 ORDER BY created_at DESC LIMIT 1`, - channelID, - ).Scan(&parentID) + // Use cursor for parent, compute sibling_index + parentID, _ := getActiveLeaf(channelID, userID) + siblingIdx := nextSiblingIndex(channelID, parentID) + + participantType := "user" + participantID := userID + if req.Role == "assistant" { + participantType = "model" + if req.Model != "" { + participantID = req.Model + } + } var msg messageResponse err := database.DB.QueryRow(` - INSERT INTO messages (channel_id, role, content, model, parent_id, participant_type, participant_id) - VALUES ($1, $2, $3, $4, $5, $6, $7) - RETURNING id, channel_id, role, content, model, tokens_used, parent_id, created_at + INSERT INTO messages (channel_id, role, content, model, parent_id, + participant_type, participant_id, sibling_index) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id, channel_id, role, content, model, tokens_used, parent_id, + sibling_index, created_at `, channelID, req.Role, req.Content, req.Model, parentID, - func() string { - if req.Role == "assistant" { - return "model" - } - return "user" - }(), - func() string { - if req.Role == "assistant" && req.Model != "" { - return req.Model - } - return userID - }(), + participantType, participantID, siblingIdx, ).Scan( &msg.ID, &msg.ChannelID, &msg.Role, &msg.Content, - &msg.Model, &msg.TokensUsed, &msg.ParentID, &msg.CreatedAt, + &msg.Model, &msg.TokensUsed, &msg.ParentID, + &msg.SiblingIndex, &msg.CreatedAt, ) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create message"}) return } + msg.SiblingCount = getSiblingCount(channelID, msg.ParentID) - // Touch the channel's updated_at so it sorts to top of list + _ = updateCursor(channelID, userID, msg.ID) _, _ = database.DB.Exec(`UPDATE channels SET updated_at = NOW() WHERE id = $1`, channelID) c.JSON(http.StatusCreated, msg) } -// ── Regenerate (stub for #8 Chat Engine) ──── +// ── Edit Message (create sibling) ─────────── +// POST /channels/:id/messages/:msgId/edit +// +// Creates a new user message as a sibling of the target (same parent_id). +// Does NOT auto-trigger completion — the frontend sends a separate request. -func (h *MessageHandler) Regenerate(c *gin.Context) { - c.JSON(http.StatusNotImplemented, gin.H{ - "error": "regenerate requires Chat Engine (ticket #8)", - }) +func (h *MessageHandler) EditMessage(c *gin.Context) { + var req editRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + userID := getUserID(c) + channelID := c.Param("id") + messageID := c.Param("msgId") + + if !userOwnsChannel(c, channelID, userID) { + return + } + + // Load target — must exist, belong to channel, be a user message + var targetParentID *string + var targetRole string + err := database.DB.QueryRow(` + SELECT parent_id, role FROM messages + WHERE id = $1 AND channel_id = $2 AND deleted_at IS NULL + `, messageID, channelID).Scan(&targetParentID, &targetRole) + + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "message not found"}) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load message"}) + return + } + if targetRole != "user" { + c.JSON(http.StatusBadRequest, gin.H{"error": "can only edit user messages"}) + return + } + + // Create sibling: same parent_id as the target + siblingIdx := nextSiblingIndex(channelID, targetParentID) + + var msg messageResponse + err = database.DB.QueryRow(` + INSERT INTO messages (channel_id, role, content, parent_id, + participant_type, participant_id, sibling_index) + VALUES ($1, 'user', $2, $3, 'user', $4, $5) + RETURNING id, channel_id, role, content, model, tokens_used, parent_id, + sibling_index, created_at + `, channelID, req.Content, targetParentID, userID, siblingIdx, + ).Scan( + &msg.ID, &msg.ChannelID, &msg.Role, &msg.Content, + &msg.Model, &msg.TokensUsed, &msg.ParentID, + &msg.SiblingIndex, &msg.CreatedAt, + ) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create edit"}) + return + } + msg.SiblingCount = getSiblingCount(channelID, msg.ParentID) + + // Cursor now points to the new sibling (it's a leaf — no children yet) + _ = updateCursor(channelID, userID, msg.ID) + _, _ = database.DB.Exec(`UPDATE channels SET updated_at = NOW() WHERE id = $1`, channelID) + + c.JSON(http.StatusCreated, msg) } -// ── Stream (stub for #8 Chat Engine) ──────── +// ── Regenerate / Complete ────────────────────── +// POST /channels/:id/messages/:msgId/regenerate +// +// Two modes depending on target role: +// - assistant message → creates a NEW assistant sibling (same parent_id). +// Context is root → target's parent. ("Give me a different response.") +// - user message → creates a child assistant response. +// Context is root → target. ("Respond to this message.") +// +// The second mode is used after editing: the edit endpoint creates a +// sibling user message, then the frontend calls regenerate on it to +// get an assistant response without duplicating the user message. -func (h *MessageHandler) Stream(c *gin.Context) { - c.JSON(http.StatusNotImplemented, gin.H{ - "error": "streaming requires Chat Engine (ticket #8)", +func (h *MessageHandler) Regenerate(c *gin.Context) { + var req regenerateRequest + _ = c.ShouldBindJSON(&req) // body is optional + + userID := getUserID(c) + channelID := c.Param("id") + messageID := c.Param("msgId") + + if !userOwnsChannel(c, channelID, userID) { + return + } + + // Load target message + var targetParentID *string + var targetRole string + err := database.DB.QueryRow(` + SELECT parent_id, role FROM messages + WHERE id = $1 AND channel_id = $2 AND deleted_at IS NULL + `, messageID, channelID).Scan(&targetParentID, &targetRole) + + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "message not found"}) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load message"}) + return + } + if targetRole != "assistant" && targetRole != "user" { + c.JSON(http.StatusBadRequest, gin.H{"error": "can only regenerate user or assistant messages"}) + return + } + + // Determine context path and new message's parent based on target role: + // + // assistant target: context = root → target's parent (exclude target) + // new parent = target's parent (sibling of target) + // + // user target: context = root → target (include target) + // new parent = target itself (child of target) + var contextPath []PathMessage + var newParentID *string + + if targetRole == "assistant" { + if targetParentID == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "cannot regenerate root message"}) + return + } + contextPath, err = getPathToLeaf(channelID, *targetParentID) + newParentID = targetParentID + } else { + // user message — respond to it + contextPath, err = getPathToLeaf(channelID, messageID) + newParentID = &messageID + } + + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to build context"}) + return + } + + // ── Resolve model + provider ── + + comp := NewCompletionHandler() + + var presetSystemPrompt string + model := req.Model + apiConfigID := req.APIConfigID + temperature := req.Temperature + maxTokens := req.MaxTokens + + if req.PresetID != "" { + preset := ResolvePreset(req.PresetID, userID) + if preset == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "preset not found"}) + return + } + if model == "" { + model = preset.BaseModelID + } + if apiConfigID == "" && preset.APIConfigID != nil { + apiConfigID = *preset.APIConfigID + } + if temperature == nil && preset.Temperature != nil { + temperature = preset.Temperature + } + if maxTokens == 0 && preset.MaxTokens != nil { + maxTokens = *preset.MaxTokens + } + if preset.SystemPrompt != "" { + presetSystemPrompt = preset.SystemPrompt + } + } + + // Fallback: channel's stored model + if model == "" { + var channelModel *string + _ = database.DB.QueryRow(`SELECT model FROM channels WHERE id = $1`, channelID).Scan(&channelModel) + if channelModel != nil { + model = *channelModel + } + } + + providerCfg, providerID, model, configID, err := comp.resolveConfig(userID, channelID, completionRequest{ + Model: model, + APIConfigID: apiConfigID, + }) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + provider, err := providers.Get(providerID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Build LLM message array + llmMessages := make([]providers.Message, 0, len(contextPath)+1) + + if presetSystemPrompt != "" { + llmMessages = append(llmMessages, providers.Message{Role: "system", Content: presetSystemPrompt}) + } else { + var systemPrompt *string + _ = database.DB.QueryRow(`SELECT system_prompt FROM channels WHERE id = $1`, channelID).Scan(&systemPrompt) + if systemPrompt != nil && *systemPrompt != "" { + llmMessages = append(llmMessages, providers.Message{Role: "system", Content: *systemPrompt}) + } + } + + for _, m := range contextPath { + if m.Role == "system" { + continue + } + llmMessages = append(llmMessages, providers.Message{Role: m.Role, Content: m.Content}) + } + + provReq := providers.CompletionRequest{ + Model: model, + Messages: llmMessages, + } + + caps := comp.getModelCapabilities(model, configID) + if maxTokens > 0 { + provReq.MaxTokens = maxTokens + } else { + provReq.MaxTokens = providers.ResolveMaxOutput(model, caps) + } + if temperature != nil { + provReq.Temperature = temperature + } + + // ── Stream the response ── + + ch, err := provider.StreamCompletion(c.Request.Context(), providerCfg, provReq) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": "provider error: " + err.Error()}) + return + } + + c.Header("Content-Type", "text/event-stream") + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + c.Header("X-Accel-Buffering", "no") + c.Status(http.StatusOK) + + var fullContent string + flusher, _ := c.Writer.(http.Flusher) + + for event := range ch { + if event.Error != nil { + fmt.Fprintf(c.Writer, "data: {\"error\":\"%s\"}\n\n", event.Error.Error()) + if flusher != nil { + flusher.Flush() + } + break + } + + if event.Delta != "" { + fullContent += event.Delta + fmt.Fprintf(c.Writer, "data: {\"choices\":[{\"delta\":{\"content\":%q},\"finish_reason\":null}],\"model\":%q}\n\n", + event.Delta, model) + if flusher != nil { + flusher.Flush() + } + } + + if event.Done { + finishReason := event.FinishReason + if finishReason == "" { + finishReason = "stop" + } + fmt.Fprintf(c.Writer, "data: {\"choices\":[{\"delta\":{},\"finish_reason\":%q}],\"model\":%q}\n\n", + finishReason, model) + io.WriteString(c.Writer, "data: [DONE]\n\n") + if flusher != nil { + flusher.Flush() + } + break + } + } + + // Persist as child or sibling depending on target role + if fullContent != "" { + siblingIdx := nextSiblingIndex(channelID, newParentID) + + var newID string + err := database.DB.QueryRow(` + INSERT INTO messages (channel_id, role, content, model, + parent_id, participant_type, participant_id, sibling_index) + VALUES ($1, 'assistant', $2, $3, $4, 'model', $5, $6) + RETURNING id + `, channelID, fullContent, model, newParentID, model, siblingIdx).Scan(&newID) + + if err != nil { + log.Printf("Failed to persist regenerated message: %v", err) + } else { + _ = updateCursor(channelID, userID, newID) + + // Send message metadata in a final SSE event so frontend can update + msgJSON, _ := json.Marshal(gin.H{ + "id": newID, + "parent_id": newParentID, + "sibling_index": siblingIdx, + "sibling_count": getSiblingCount(channelID, newParentID), + }) + fmt.Fprintf(c.Writer, "data: {\"switchboard_meta\":%s}\n\n", msgJSON) + if flusher != nil { + flusher.Flush() + } + } + + _, _ = database.DB.Exec(`UPDATE channels SET updated_at = NOW() WHERE id = $1`, channelID) + } +} + +// ── Switch Branch (update cursor) ─────────── +// PUT /channels/:id/cursor +// +// Navigates to a different branch. If the target message has children, +// follows the first-child chain to find the leaf. + +func (h *MessageHandler) UpdateCursor(c *gin.Context) { + var req cursorRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + userID := getUserID(c) + channelID := c.Param("id") + + if !userOwnsChannel(c, channelID, userID) { + return + } + + // Verify the target message belongs to this channel + var msgChannelID string + err := database.DB.QueryRow(` + SELECT channel_id FROM messages + WHERE id = $1 AND deleted_at IS NULL + `, req.ActiveLeafID).Scan(&msgChannelID) + + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "message not found"}) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify message"}) + return + } + if msgChannelID != channelID { + c.JSON(http.StatusForbidden, gin.H{"error": "message does not belong to this channel"}) + return + } + + // Walk down to the leaf from the target + leafID, err := findLeafFromMessage(req.ActiveLeafID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to find leaf"}) + return + } + + if err := updateCursor(channelID, userID, leafID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update cursor"}) + return + } + + // Return the new active path + path, err := getPathToLeaf(channelID, leafID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load path"}) + return + } + + c.JSON(http.StatusOK, gin.H{"path": path, "active_leaf_id": leafID}) +} + +// ── List Siblings ─────────────────────────── +// GET /channels/:id/messages/:msgId/siblings +// +// Returns all siblings of a message (messages sharing the same parent_id). +// Used by the branch navigator to populate the arrow navigation. + +func (h *MessageHandler) ListSiblings(c *gin.Context) { + userID := getUserID(c) + channelID := c.Param("id") + messageID := c.Param("msgId") + + if !userOwnsChannel(c, channelID, userID) { + return + } + + siblings, currentIdx, err := getSiblings(messageID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "message not found"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "siblings": siblings, + "current_index": currentIdx, + "total": len(siblings), }) } diff --git a/server/handlers/tree.go b/server/handlers/tree.go new file mode 100644 index 0000000..f13d322 --- /dev/null +++ b/server/handlers/tree.go @@ -0,0 +1,296 @@ +package handlers + +import ( + "database/sql" + "fmt" + + "git.gobha.me/xcaliber/chat-switchboard/database" +) + +// ── Tree Types ────────────────────────────── + +// PathMessage represents a single message in an active path, enriched +// with sibling metadata for the frontend branch indicator. +type PathMessage struct { + ID string `json:"id"` + ParentID *string `json:"parent_id"` + Role string `json:"role"` + Content string `json:"content"` + Model *string `json:"model"` + TokensUsed *int `json:"tokens_used,omitempty"` + SiblingCount int `json:"sibling_count"` + SiblingIndex int `json:"sibling_index"` + ParticipantType string `json:"participant_type,omitempty"` + ParticipantID string `json:"participant_id,omitempty"` + CreatedAt string `json:"created_at"` +} + +// SiblingInfo is a lightweight entry for the siblings listing endpoint. +type SiblingInfo struct { + ID string `json:"id"` + Role string `json:"role"` + Model *string `json:"model,omitempty"` + SiblingIndex int `json:"sibling_index"` + Preview string `json:"preview"` // first ~80 chars of content + CreatedAt string `json:"created_at"` +} + +// ── Get Active Leaf ───────────────────────── + +// getActiveLeaf returns the cursor's active_leaf_id for a user in a channel. +// Falls back to the chronologically latest live message if no cursor exists. +func getActiveLeaf(channelID, userID string) (*string, error) { + var leafID *string + + // Try cursor first + err := database.DB.QueryRow(` + SELECT active_leaf_id FROM channel_cursors + WHERE channel_id = $1 AND user_id = $2 + `, channelID, userID).Scan(&leafID) + + if err == nil && leafID != nil { + // Verify the leaf still exists and isn't deleted + var exists bool + database.DB.QueryRow(` + SELECT EXISTS(SELECT 1 FROM messages WHERE id = $1 AND deleted_at IS NULL) + `, *leafID).Scan(&exists) + if exists { + return leafID, nil + } + } + + // Fallback: latest live message in channel + var fallbackID string + err = database.DB.QueryRow(` + SELECT id FROM messages + WHERE channel_id = $1 AND deleted_at IS NULL + ORDER BY created_at DESC LIMIT 1 + `, channelID).Scan(&fallbackID) + if err == sql.ErrNoRows { + return nil, nil // empty channel + } + if err != nil { + return nil, err + } + return &fallbackID, nil +} + +// ── Get Active Path ───────────────────────── + +// getActivePath walks from the active leaf up to the root via parent_id, +// returning messages in root-first order. This is what gets sent to the LLM. +func getActivePath(channelID, userID string) ([]PathMessage, error) { + leafID, err := getActiveLeaf(channelID, userID) + if err != nil { + return nil, err + } + if leafID == nil { + return []PathMessage{}, nil // empty channel + } + + return getPathToLeaf(channelID, *leafID) +} + +// getPathToLeaf walks from a specific leaf up to root, returning root-first. +func getPathToLeaf(channelID, leafID string) ([]PathMessage, error) { + rows, err := database.DB.Query(` + WITH RECURSIVE path AS ( + -- Anchor: start at the leaf + SELECT id, parent_id, role, content, model, tokens_used, + participant_type, participant_id, sibling_index, created_at, + 0 AS depth + FROM messages + WHERE id = $1 AND channel_id = $2 AND deleted_at IS NULL + + UNION ALL + + -- Walk up via parent_id + SELECT m.id, m.parent_id, m.role, m.content, m.model, m.tokens_used, + m.participant_type, m.participant_id, m.sibling_index, m.created_at, + p.depth + 1 + FROM messages m + JOIN path p ON m.id = p.parent_id + WHERE m.deleted_at IS NULL + ) + SELECT id, parent_id, role, content, model, tokens_used, + participant_type, participant_id, sibling_index, created_at + FROM path + ORDER BY depth DESC + `, leafID, channelID) + if err != nil { + return nil, fmt.Errorf("getPathToLeaf: %w", err) + } + defer rows.Close() + + var path []PathMessage + for rows.Next() { + var m PathMessage + var participantType, participantID sql.NullString + if err := rows.Scan( + &m.ID, &m.ParentID, &m.Role, &m.Content, &m.Model, &m.TokensUsed, + &participantType, &participantID, &m.SiblingIndex, &m.CreatedAt, + ); err != nil { + return nil, fmt.Errorf("getPathToLeaf scan: %w", err) + } + if participantType.Valid { + m.ParticipantType = participantType.String + } + if participantID.Valid { + m.ParticipantID = participantID.String + } + path = append(path, m) + } + + // Enrich with sibling counts + for i := range path { + path[i].SiblingCount = getSiblingCount(channelID, path[i].ParentID) + } + + return path, nil +} + +// ── Sibling Queries ───────────────────────── + +// getSiblingCount returns how many live siblings share the same parent_id. +// Root messages (parent_id IS NULL) count all roots in the same channel. +func getSiblingCount(channelID string, parentID *string) int { + var count int + if parentID == nil { + database.DB.QueryRow(` + SELECT COUNT(*) FROM messages + WHERE channel_id = $1 AND parent_id IS NULL AND deleted_at IS NULL + `, channelID).Scan(&count) + } else { + database.DB.QueryRow(` + SELECT COUNT(*) FROM messages + WHERE parent_id = $1 AND deleted_at IS NULL + `, *parentID).Scan(&count) + } + if count == 0 { + count = 1 + } + return count +} + +// getSiblings returns all live siblings of a given message (including itself), +// ordered by sibling_index. +func getSiblings(messageID string) ([]SiblingInfo, int, error) { + // First get the parent_id of the target message + var parentID *string + var channelID string + err := database.DB.QueryRow(` + SELECT parent_id, channel_id FROM messages + WHERE id = $1 AND deleted_at IS NULL + `, messageID).Scan(&parentID, &channelID) + if err != nil { + return nil, 0, fmt.Errorf("message not found: %w", err) + } + + var rows *sql.Rows + if parentID == nil { + // Root messages: siblings are other roots in the same channel + rows, err = database.DB.Query(` + SELECT id, role, model, sibling_index, LEFT(content, 80), created_at + FROM messages + WHERE channel_id = $1 AND parent_id IS NULL AND deleted_at IS NULL + ORDER BY sibling_index, created_at + `, channelID) + } else { + rows, err = database.DB.Query(` + SELECT id, role, model, sibling_index, LEFT(content, 80), created_at + FROM messages + WHERE parent_id = $1 AND deleted_at IS NULL + ORDER BY sibling_index, created_at + `, *parentID) + } + if err != nil { + return nil, 0, err + } + defer rows.Close() + + var siblings []SiblingInfo + currentIdx := 0 + for i := 0; rows.Next(); i++ { + var s SiblingInfo + if err := rows.Scan(&s.ID, &s.Role, &s.Model, &s.SiblingIndex, &s.Preview, &s.CreatedAt); err != nil { + return nil, 0, err + } + if s.ID == messageID { + currentIdx = i + } + siblings = append(siblings, s) + } + + return siblings, currentIdx, nil +} + +// ── Find Leaf ─────────────────────────────── + +// findLeafFromMessage walks down from a message to find the deepest descendant. +// Follows the first child (lowest sibling_index) at each level. +// Used when switching branches: clicking a mid-tree sibling navigates to its leaf. +func findLeafFromMessage(messageID string) (string, error) { + var leafID string + err := database.DB.QueryRow(` + WITH RECURSIVE descendants AS ( + SELECT id, 0 AS depth + FROM messages + WHERE id = $1 AND deleted_at IS NULL + + UNION ALL + + SELECT child.id, d.depth + 1 + FROM messages child + JOIN descendants d ON child.parent_id = d.id + WHERE child.deleted_at IS NULL + AND child.sibling_index = ( + SELECT MIN(sibling_index) FROM messages + WHERE parent_id = d.id AND deleted_at IS NULL + ) + ) + SELECT id FROM descendants + ORDER BY depth DESC + LIMIT 1 + `, messageID).Scan(&leafID) + + if err != nil { + return messageID, nil // if anything fails, just use the message itself + } + return leafID, nil +} + +// ── Next Sibling Index ────────────────────── + +// nextSiblingIndex returns the next sibling_index for a new child of parentID. +// For root messages (parentID is nil), counts existing roots in the channel. +func nextSiblingIndex(channelID string, parentID *string) int { + var maxIdx sql.NullInt64 + if parentID == nil { + database.DB.QueryRow(` + SELECT MAX(sibling_index) FROM messages + WHERE channel_id = $1 AND parent_id IS NULL AND deleted_at IS NULL + `, channelID).Scan(&maxIdx) + } else { + database.DB.QueryRow(` + SELECT MAX(sibling_index) FROM messages + WHERE parent_id = $1 AND deleted_at IS NULL + `, *parentID).Scan(&maxIdx) + } + if !maxIdx.Valid { + return 0 + } + return int(maxIdx.Int64) + 1 +} + +// ── Update Cursor ─────────────────────────── + +// updateCursor upserts the channel_cursors row for a user. +func updateCursor(channelID, userID, messageID string) error { + _, err := database.DB.Exec(` + INSERT INTO channel_cursors (channel_id, user_id, active_leaf_id) + VALUES ($1, $2, $3) + ON CONFLICT (channel_id, user_id) DO UPDATE SET + active_leaf_id = $3, updated_at = NOW() + `, channelID, userID, messageID) + return err +} diff --git a/server/main.go b/server/main.go index b1ad371..d8dba9d 100644 --- a/server/main.go +++ b/server/main.go @@ -104,10 +104,16 @@ func main() { protected.GET("/channels/:id/messages", msgs.ListMessages) protected.POST("/channels/:id/messages", msgs.CreateMessage) - // ── Chat Engine (#8) ──────────────── + // Message tree (forking) + protected.GET("/channels/:id/path", msgs.GetActivePath) + protected.PUT("/channels/:id/cursor", msgs.UpdateCursor) + protected.POST("/channels/:id/messages/:msgId/edit", msgs.EditMessage) + protected.POST("/channels/:id/messages/:msgId/regenerate", msgs.Regenerate) + protected.GET("/channels/:id/messages/:msgId/siblings", msgs.ListSiblings) + + // Chat Completions comp := handlers.NewCompletionHandler() protected.POST("/chat/completions", comp.Complete) - protected.POST("/channels/:id/regenerate", msgs.Regenerate) // TODO: wire to completion handler // API Configs apiCfg := handlers.NewAPIConfigHandler() diff --git a/src/css/styles.css b/src/css/styles.css index 63712d5..1b76a04 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -428,6 +428,79 @@ a:hover { text-decoration: underline; } } .msg-action-btn:hover { background: var(--bg-hover); color: var(--text); } +/* Branch navigation indicator */ +.branch-nav { + display: inline-flex; + align-items: center; + gap: 2px; + font-size: 12px; + color: var(--text-3); +} +.branch-arrow { + background: none; + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text-2); + cursor: pointer; + padding: 0 5px; + font-size: 16px; + line-height: 18px; + transition: var(--transition); +} +.branch-arrow:hover:not(:disabled) { color: var(--accent); border-color: var(--accent); } +.branch-arrow:disabled { opacity: 0.25; cursor: default; } +.branch-pos { + font-size: 11px; + font-variant-numeric: tabular-nums; + min-width: 28px; + text-align: center; + user-select: none; +} + +/* Inline message editing */ +.msg-edit-input { + width: 100%; + background: var(--bg-surface); + border: 1px solid var(--accent); + border-radius: var(--radius); + color: var(--text); + padding: 10px 12px; + font-family: var(--font); + font-size: var(--msg-font, 14px); + line-height: 1.6; + resize: vertical; + min-height: 60px; + max-height: 400px; + outline: none; +} +.msg-edit-input:focus { border-color: var(--accent-hover); box-shadow: 0 0 0 2px var(--accent-dim); } +.msg-edit-actions { + display: flex; + gap: 8px; + margin-top: 8px; + justify-content: flex-end; +} +.msg-edit-cancel, .msg-edit-submit { + padding: 6px 14px; + border-radius: var(--radius); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: var(--transition); + border: 1px solid var(--border); +} +.msg-edit-cancel { + background: var(--bg-raised); + color: var(--text-2); +} +.msg-edit-cancel:hover { background: var(--bg-hover); color: var(--text); } +.msg-edit-submit { + background: var(--accent); + color: #fff; + border-color: var(--accent); +} +.msg-edit-submit:hover { background: var(--accent-hover); } + .msg-text { font-size: var(--msg-font, 14px); line-height: 1.7; } /* ── Markdown Content ────────────────────── */ @@ -755,13 +828,14 @@ button { font-family: var(--font); cursor: pointer; } position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: none; align-items: center; justify-content: center; z-index: 1000; backdrop-filter: blur(2px); + padding: 2rem; /* keeps modal away from viewport edges at any zoom */ } .modal-overlay.active { display: flex; } .modal { background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius-lg); width: 90%; max-width: 520px; - max-height: 85vh; display: flex; flex-direction: column; + max-height: 100%; display: flex; flex-direction: column; animation: modal-in 0.15s ease; } .modal-wide { max-width: 700px; } @@ -788,13 +862,44 @@ button { font-family: var(--font); cursor: pointer; } .settings-section:last-child { border-bottom: none; margin-bottom: 0; } .settings-section h3 { font-size: 12px; font-weight: 600; color: var(--text-3); margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.06em; } +/* Modal tab bars — shared horizontal scroll with arrow navigation */ +.modal-tabs-wrap { + position: relative; flex-shrink: 0; + border-bottom: 1px solid var(--border); background: var(--bg-raised); +} +.modal-tabs { + display: flex; padding: 0 16px; + border-bottom: 1px solid var(--border); background: var(--bg-raised); + overflow-x: auto; overflow-y: hidden; + scrollbar-width: none; -ms-overflow-style: none; + -webkit-overflow-scrolling: touch; + scroll-behavior: smooth; + flex-shrink: 0; +} +.modal-tabs::-webkit-scrollbar { display: none; } +/* When wrapped, the wrapper owns the border */ +.modal-tabs-wrap .modal-tabs { border-bottom: none; background: none; } + +.tab-arrow { + position: absolute; top: 0; bottom: 1px; /* 1px for border */ width: 28px; + display: flex; align-items: center; justify-content: center; + background: var(--bg-raised); border: none; color: var(--text-2); + cursor: pointer; font-size: 16px; z-index: 2; opacity: 0; + pointer-events: none; transition: opacity 0.15s ease; +} +.tab-arrow.visible { opacity: 1; pointer-events: auto; } +.tab-arrow:hover { color: var(--accent); } +.tab-arrow-left { left: 0; padding-left: 2px; box-shadow: 4px 0 8px -2px rgba(0,0,0,0.15); } +.tab-arrow-right { right: 0; padding-right: 2px; box-shadow: -4px 0 8px -2px rgba(0,0,0,0.15); } + /* Settings tabs */ -.settings-tabs { display: flex; border-bottom: 1px solid var(--border); padding: 0 16px; background: var(--bg-raised); gap: 0; } +.settings-tabs { gap: 0; } .settings-tabs .settings-tab { padding: 10px 14px; background: none; border: none; border-radius: 0; color: var(--text-3); cursor: pointer; font-size: 13px; font-family: var(--font); border-bottom: 2px solid transparent; transition: color var(--transition); outline: none; -webkit-appearance: none; appearance: none; + white-space: nowrap; flex-shrink: 0; } .settings-tabs .settings-tab:hover { color: var(--text-2); } .settings-tabs .settings-tab.active { color: var(--accent); border-bottom-color: var(--accent); } @@ -817,10 +922,11 @@ button { font-family: var(--font); cursor: pointer; } .badge-global { font-size: 10px; color: var(--text-3); background: var(--bg-raised); padding: 2px 8px; border-radius: 4px; } /* Admin tabs */ -.admin-tabs { display: flex; border-bottom: 1px solid var(--border); padding: 0 16px; background: var(--bg-raised); } +.admin-tabs { } .admin-tab { padding: 10px 14px; background: none; border: none; color: var(--text-3); cursor: pointer; font-size: 13px; border-bottom: 2px solid transparent; + white-space: nowrap; flex-shrink: 0; } .admin-tab.active { color: var(--accent); border-bottom-color: var(--accent); } @@ -937,9 +1043,9 @@ button { font-family: var(--font); cursor: pointer; } /* ── Debug Modal ─────────────────────────── */ -.debug-modal { max-width: 900px; height: 80vh; overflow: hidden; } -.debug-tabs { display: flex; border-bottom: 1px solid var(--border); padding: 0 16px; background: var(--bg-raised); } -.debug-tab { padding: 8px 12px; background: none; border: none; color: var(--text-3); cursor: pointer; font-size: 12px; border-bottom: 2px solid transparent; } +.debug-modal { max-width: 900px; height: 100%; overflow: hidden; } +.debug-tabs { } +.debug-tab { padding: 8px 12px; background: none; border: none; color: var(--text-3); cursor: pointer; font-size: 12px; border-bottom: 2px solid transparent; white-space: nowrap; flex-shrink: 0; } .debug-tab.active { color: var(--accent); border-bottom-color: var(--accent); } .debug-modal-body { flex: 1; overflow: hidden; padding: 0 !important; } .debug-tab-content { height: 100%; display: flex; flex-direction: column; } @@ -980,4 +1086,8 @@ button { font-family: var(--font); cursor: pointer; } .input-area { padding: 0 0.5rem 0.5rem; } .input-wrap textarea { font-size: 16px; } /* prevent iOS zoom */ .mobile-menu-btn { display: flex; } + .modal-overlay { padding: 0.75rem; } + .modal { width: 100%; } + .tab-arrow { width: 36px; font-size: 20px; } /* larger touch target */ + .modal-tabs { padding: 0 12px; } } diff --git a/src/index.html b/src/index.html index 08e7249..669372c 100644 --- a/src/index.html +++ b/src/index.html @@ -118,9 +118,6 @@
- @@ -201,7 +198,7 @@