Changeset 0.8.0 (#39)

This commit is contained in:
2026-02-21 16:53:07 +00:00
parent b72bfbb5b4
commit 494b1aa981
14 changed files with 1510 additions and 150 deletions

96
FORKING_IMPL.md Normal file
View File

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

View File

@@ -1 +1 @@
0.7.0
0.7.1

View File

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

View File

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

View File

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

View File

@@ -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),
})
}

296
server/handlers/tree.go Normal file
View File

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

View File

@@ -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()

View File

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

View File

@@ -118,9 +118,6 @@
<div class="input-wrap">
<textarea id="messageInput" placeholder="Send a message..." rows="1"></textarea>
<div class="input-actions">
<button class="action-btn" id="regenerateBtn" style="display:none" title="Regenerate">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
</button>
<button class="action-btn stop-btn" id="stopBtn" title="Stop">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
</button>
@@ -201,7 +198,7 @@
<div class="modal-overlay" id="settingsModal">
<div class="modal">
<div class="modal-header"><h2>Settings</h2><button class="modal-close" id="settingsCloseBtn"></button></div>
<div class="settings-tabs">
<div class="modal-tabs settings-tabs">
<button class="settings-tab active" data-stab="general" onclick="UI.switchSettingsTab('general')">General</button>
<button class="settings-tab" data-stab="appearance" onclick="UI.switchSettingsTab('appearance')">Appearance</button>
<button class="settings-tab" data-stab="providers" onclick="UI.switchSettingsTab('providers')">Providers</button>
@@ -283,7 +280,7 @@
<div class="modal-overlay" id="adminModal">
<div class="modal modal-wide">
<div class="modal-header"><h2>Admin Panel</h2><button class="modal-close" id="adminCloseBtn"></button></div>
<div class="admin-tabs">
<div class="modal-tabs admin-tabs">
<button class="admin-tab active" data-tab="users">Users</button>
<button class="admin-tab" data-tab="providers">Providers</button>
<button class="admin-tab" data-tab="models">Models</button>
@@ -408,7 +405,7 @@
<div class="modal-overlay" id="debugModal">
<div class="modal debug-modal">
<div class="modal-header"><h2>Debug Log</h2><button class="modal-close" onclick="closeDebugModal()"></button></div>
<div class="debug-tabs">
<div class="modal-tabs debug-tabs">
<button class="debug-tab active" data-tab="console" onclick="switchDebugTab('console')">Console (<span id="debugConsoleCount">0</span>)</button>
<button class="debug-tab" data-tab="network" onclick="switchDebugTab('network')">Network (<span id="debugNetworkCount">0</span>)</button>
<button class="debug-tab" data-tab="state" onclick="switchDebugTab('state')">State</button>

View File

@@ -122,6 +122,55 @@ const API = {
return this._get(`/api/v1/channels/${channelId}/messages?page=${page}&per_page=${perPage}`);
},
// ── Message Tree (forking) ───────────────
getActivePath(channelId) {
return this._get(`/api/v1/channels/${channelId}/path`);
},
editMessage(channelId, messageId, content) {
return this._post(`/api/v1/channels/${channelId}/messages/${messageId}/edit`, { content });
},
async streamRegenerate(channelId, messageId, signal, model, presetId, apiConfigId) {
const body = {};
if (model) body.model = model;
if (presetId) body.preset_id = presetId;
if (apiConfigId) body.api_config_id = apiConfigId;
let resp = await fetch(BASE + `/api/v1/channels/${channelId}/messages/${messageId}/regenerate`, {
method: 'POST',
headers: this._authHeaders(),
body: JSON.stringify(body),
signal
});
if (resp.status === 401 && this.refreshToken) {
if (await this.refresh()) {
resp = await fetch(BASE + `/api/v1/channels/${channelId}/messages/${messageId}/regenerate`, {
method: 'POST',
headers: this._authHeaders(),
body: JSON.stringify(body),
signal
});
}
}
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || `HTTP ${resp.status}`);
}
return resp;
},
updateCursor(channelId, activeLeafId) {
return this._put(`/api/v1/channels/${channelId}/cursor`, { active_leaf_id: activeLeafId });
},
listSiblings(channelId, messageId) {
return this._get(`/api/v1/channels/${channelId}/messages/${messageId}/siblings`);
},
// ── Completions ──────────────────────────
async streamCompletion(channelId, content, model, signal, apiConfigId, presetId) {

View File

@@ -242,9 +242,17 @@ async function selectChat(chatId) {
if (chat.messages.length === 0 && chat.messageCount > 0) {
try {
const resp = await API.listMessages(chatId);
chat.messages = (resp.data || []).map(m => ({
role: m.role, content: m.content, model: m.model || '', timestamp: m.created_at
const resp = await API.getActivePath(chatId);
chat.messages = (resp.path || []).map(m => ({
id: m.id,
parent_id: m.parent_id || null,
role: m.role,
content: m.content,
model: m.model || '',
modelName: '',
timestamp: m.created_at,
siblingCount: m.sibling_count || 1,
siblingIndex: m.sibling_index || 0,
}));
} catch (e) {
console.error('Failed to load messages:', e.message);
@@ -309,7 +317,8 @@ async function sendMessage() {
const chat = App.chats.find(c => c.id === App.currentChatId);
if (!chat) return;
chat.messages.push({ role: 'user', content: text, timestamp: new Date().toISOString() });
// Optimistic: show user message immediately
chat.messages.push({ role: 'user', content: text, timestamp: new Date().toISOString(), siblingCount: 1, siblingIndex: 0 });
UI.renderMessages(chat.messages);
App.isGenerating = true;
@@ -318,14 +327,15 @@ async function sendMessage() {
try {
const resp = await API.streamCompletion(App.currentChatId, text, model, App.abortController.signal, modelInfo?.configId, presetId);
const assistantContent = await UI.streamResponse(resp, chat.messages, modelInfo?.name);
chat.messages.push({ role: 'assistant', content: assistantContent, model, modelName: modelInfo?.name || model, timestamp: new Date().toISOString() });
chat.messageCount = chat.messages.length;
UI.renderMessages(chat.messages);
await UI.streamResponse(resp, chat.messages, modelInfo?.name);
// Reload active path from server to get proper IDs and sibling metadata
await reloadActivePath();
UI.showRegenerate(true);
} catch (e) {
if (e.name === 'AbortError') {
UI.toast('Generation stopped', 'warning');
await reloadActivePath();
} else {
console.error('Completion error:', e);
const msg = e.message || '';
@@ -348,34 +358,63 @@ async function sendMessage() {
function stopGeneration() { if (App.abortController) App.abortController.abort(); }
async function regenerate() {
if (App.isGenerating || !App.currentChatId) return;
// ── Reload Active Path from Server ──────────
async function reloadActivePath() {
if (!App.currentChatId) return;
const chat = App.chats.find(c => c.id === App.currentChatId);
if (!chat || chat.messages.length === 0) return;
if (!chat) return;
while (chat.messages.length && chat.messages[chat.messages.length - 1].role === 'assistant') chat.messages.pop();
if (chat.messages.length === 0) return;
try {
const resp = await API.getActivePath(App.currentChatId);
chat.messages = (resp.path || []).map(m => ({
id: m.id,
parent_id: m.parent_id || null,
role: m.role,
content: m.content,
model: m.model || '',
modelName: '',
timestamp: m.created_at,
siblingCount: m.sibling_count || 1,
siblingIndex: m.sibling_index || 0,
}));
chat.messageCount = chat.messages.length;
UI.renderMessages(chat.messages);
} catch (e) {
console.error('Failed to reload path:', e.message);
}
}
UI.renderMessages(chat.messages);
const lastUser = chat.messages[chat.messages.length - 1];
if (lastUser.role !== 'user') return;
// ── Regenerate (tree-aware: creates sibling) ─
async function regenerateMessage(messageId) {
if (App.isGenerating || !App.currentChatId) return;
const selectedId = UI.getModelValue();
const modelInfo = App.models.find(m => m.id === selectedId);
const model = modelInfo?.baseModelId || selectedId;
const presetId = modelInfo?.isPreset ? modelInfo.presetId : null;
App.isGenerating = true;
App.abortController = new AbortController();
UI.setGenerating(true);
try {
const resp = await API.streamCompletion(App.currentChatId, lastUser.content, model, App.abortController.signal, modelInfo?.configId, presetId);
const content = await UI.streamResponse(resp, chat.messages, modelInfo?.name);
chat.messages.push({ role: 'assistant', content, model, modelName: modelInfo?.name || model, timestamp: new Date().toISOString() });
UI.renderMessages(chat.messages);
const chat = App.chats.find(c => c.id === App.currentChatId);
const resp = await API.streamRegenerate(
App.currentChatId, messageId, App.abortController.signal,
model, presetId, modelInfo?.configId
);
await UI.streamResponse(resp, chat?.messages || [], modelInfo?.name);
// Reload from server to get proper tree state
await reloadActivePath();
UI.showRegenerate(true);
} catch (e) {
if (e.name !== 'AbortError') {
if (e.name === 'AbortError') {
UI.toast('Generation stopped', 'warning');
await reloadActivePath();
} else {
const msg = e.message || '';
if (msg.includes('provider error') && msg.includes('401')) {
UI.toast('Provider API key rejected — check Settings', 'error');
@@ -388,6 +427,212 @@ async function regenerate() {
}
}
// Legacy: bottom-bar regenerate button targets the last assistant message
async function regenerate() {
if (App.isGenerating || !App.currentChatId) return;
const chat = App.chats.find(c => c.id === App.currentChatId);
if (!chat || chat.messages.length === 0) return;
// Find the last assistant message with an ID
for (let i = chat.messages.length - 1; i >= 0; i--) {
if (chat.messages[i].role === 'assistant' && chat.messages[i].id) {
return regenerateMessage(chat.messages[i].id);
}
}
UI.toast('No assistant message to regenerate', 'warning');
}
// ── Edit Message (creates sibling, triggers completion) ──
async function editMessage(messageId) {
if (App.isGenerating || !App.currentChatId) return;
const chat = App.chats.find(c => c.id === App.currentChatId);
if (!chat) return;
const msg = chat.messages.find(m => m.id === messageId);
if (!msg || msg.role !== 'user') return;
// Show inline edit UI
UI.showEditInline(messageId, msg.content);
}
async function submitEdit(messageId, newContent) {
if (App.isGenerating || !App.currentChatId) return;
if (!newContent.trim()) return;
const selectedId = UI.getModelValue();
const modelInfo = App.models.find(m => m.id === selectedId);
const model = modelInfo?.baseModelId || selectedId;
const presetId = modelInfo?.isPreset ? modelInfo.presetId : null;
App.isGenerating = true;
App.abortController = new AbortController();
UI.setGenerating(true);
try {
const chat = App.chats.find(c => c.id === App.currentChatId);
// Step 1: Create sibling user message via edit endpoint
const editResp = await API.editMessage(App.currentChatId, messageId, newContent.trim());
// Step 2: Reload path to show the new edited message
await reloadActivePath();
// Step 3: Generate assistant response as child of the new edit.
// Uses regenerate endpoint (which handles user messages by creating
// a child response, not a sibling). This avoids the duplicate user
// message that streamCompletion would create.
const resp = await API.streamRegenerate(
App.currentChatId, editResp.id, App.abortController.signal,
model, presetId, modelInfo?.configId
);
await UI.streamResponse(resp, chat?.messages || [], modelInfo?.name);
// Step 4: Final reload to get the complete tree state
await reloadActivePath();
UI.showRegenerate(true);
} catch (e) {
if (e.name === 'AbortError') {
UI.toast('Generation stopped', 'warning');
await reloadActivePath();
} else {
console.error('Edit error:', e);
UI.toast(e.message || 'Edit failed', 'error');
await reloadActivePath();
}
} finally {
App.isGenerating = false;
App.abortController = null;
UI.setGenerating(false);
}
}
function cancelEdit() {
// Re-render to remove the edit form
const chat = App.chats.find(c => c.id === App.currentChatId);
if (chat) UI.renderMessages(chat.messages);
}
// ── Switch Branch (sibling navigation) ──────
async function switchSibling(messageId, direction) {
if (App.isGenerating || !App.currentChatId) return;
try {
// Get siblings for this message
const data = await API.listSiblings(App.currentChatId, messageId);
const siblings = data.siblings || [];
const currentIdx = data.current_index ?? 0;
const targetIdx = currentIdx + direction;
if (targetIdx < 0 || targetIdx >= siblings.length) return;
const targetSibling = siblings[targetIdx];
// Update cursor to the target sibling (backend walks to leaf)
const resp = await API.updateCursor(App.currentChatId, targetSibling.id);
// Update local state with the new path
const chat = App.chats.find(c => c.id === App.currentChatId);
if (chat && resp.path) {
chat.messages = resp.path.map(m => ({
id: m.id,
parent_id: m.parent_id || null,
role: m.role,
content: m.content,
model: m.model || '',
modelName: '',
timestamp: m.created_at,
siblingCount: m.sibling_count || 1,
siblingIndex: m.sibling_index || 0,
}));
chat.messageCount = chat.messages.length;
UI.renderMessages(chat.messages);
UI.showRegenerate(chat.messages.some(m => m.role === 'assistant'));
}
} catch (e) {
console.error('Branch switch failed:', e.message);
UI.toast('Failed to switch branch', 'error');
}
}
// ── Modal Helpers ───────────────────────────
function openModal(id) {
const el = document.getElementById(id);
if (!el) return;
el.classList.add('active');
// Defer overflow check — browser needs a frame to lay out the modal
requestAnimationFrame(() => {
el.querySelectorAll('.modal-tabs').forEach(checkTabsOverflow);
});
}
function closeModal(id) {
const el = document.getElementById(id);
if (el) el.classList.remove('active');
}
// Toggle .is-scrollable on tab bars that overflow horizontally
// and inject arrow navigation buttons when needed.
function checkTabsOverflow(tabs) {
if (!tabs) return;
const scrollable = tabs.scrollWidth > tabs.clientWidth + 1;
if (!scrollable) {
// Remove wrapper if overflow went away (e.g. zoom decreased)
const wrap = tabs.parentElement;
if (wrap && wrap.classList.contains('modal-tabs-wrap')) {
wrap.parentElement.insertBefore(tabs, wrap);
wrap.remove();
}
return;
}
// Wrap if not already wrapped
if (!tabs.parentElement.classList.contains('modal-tabs-wrap')) {
const wrap = document.createElement('div');
wrap.className = 'modal-tabs-wrap';
tabs.parentElement.insertBefore(wrap, tabs);
wrap.appendChild(tabs);
// Inject arrows
const left = document.createElement('button');
left.className = 'tab-arrow tab-arrow-left';
left.innerHTML = '&#x2039;';
left.addEventListener('click', () => { tabs.scrollLeft -= 120; });
const right = document.createElement('button');
right.className = 'tab-arrow tab-arrow-right';
right.innerHTML = '&#x203A;';
right.addEventListener('click', () => { tabs.scrollLeft += 120; });
wrap.appendChild(left);
wrap.appendChild(right);
// Update arrow visibility on scroll
tabs.addEventListener('scroll', () => updateTabArrows(tabs), { passive: true });
}
updateTabArrows(tabs);
}
function updateTabArrows(tabs) {
const wrap = tabs.parentElement;
if (!wrap || !wrap.classList.contains('modal-tabs-wrap')) return;
const left = wrap.querySelector('.tab-arrow-left');
const right = wrap.querySelector('.tab-arrow-right');
if (!left || !right) return;
const atStart = tabs.scrollLeft <= 2;
const atEnd = tabs.scrollLeft + tabs.clientWidth >= tabs.scrollWidth - 2;
left.classList.toggle('visible', !atStart);
right.classList.toggle('visible', !atEnd);
}
// ── Auth Flow ────────────────────────────────
function showSplash(health) {
@@ -511,7 +756,6 @@ function initListeners() {
// Chat actions
document.getElementById('sendBtn').addEventListener('click', sendMessage);
document.getElementById('stopBtn').addEventListener('click', stopGeneration);
document.getElementById('regenerateBtn').addEventListener('click', regenerate);
// Model selector (custom dropdown)
UI.initModelDropdown();
@@ -660,13 +904,23 @@ function initListeners() {
// Close modals on overlay click
document.querySelectorAll('.modal-overlay').forEach(overlay => {
overlay.addEventListener('click', (e) => {
if (e.target === overlay) overlay.classList.remove('active');
if (e.target === overlay) closeModal(overlay.id);
});
});
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && App.isGenerating) stopGeneration();
if (e.key === 'Escape') {
if (App.isGenerating) { stopGeneration(); return; }
// Close topmost open modal
const open = [...document.querySelectorAll('.modal-overlay.active')];
if (open.length) closeModal(open[open.length - 1].id);
}
});
// Recheck tab overflow on resize / zoom changes
window.addEventListener('resize', () => {
document.querySelectorAll('.modal-overlay.active .modal-tabs').forEach(checkTabsOverflow);
});
}

View File

@@ -500,11 +500,11 @@ const DebugLog = {
function openDebugModal() {
DebugLog.render();
document.getElementById('debugModal').classList.add('active');
openModal('debugModal');
}
function closeDebugModal() {
document.getElementById('debugModal').classList.remove('active');
closeModal('debugModal');
}
function switchDebugTab(tab) {

View File

@@ -110,15 +110,37 @@ const UI = {
assistantLabel = m?.name || msg.model;
}
}
// Branch indicator: show 2/3 when siblings exist
const hasSiblings = (msg.siblingCount || 1) > 1;
const sibPos = (msg.siblingIndex || 0) + 1; // display as 1-indexed
const sibTotal = msg.siblingCount || 1;
const branchNav = hasSiblings ? `
<div class="branch-nav">
<button class="branch-arrow" onclick="switchSibling('${msg.id}', -1)"
${sibPos <= 1 ? 'disabled' : ''} title="Previous version">&#8249;</button>
<span class="branch-pos">${sibPos}/${sibTotal}</span>
<button class="branch-arrow" onclick="switchSibling('${msg.id}', 1)"
${sibPos >= sibTotal ? 'disabled' : ''} title="Next version">&#8250;</button>
</div>` : '';
// Action buttons
const msgId = msg.id ? esc(msg.id) : '';
const editBtn = isUser && msgId ? `<button class="msg-action-btn" onclick="editMessage('${msgId}')" title="Edit">Edit</button>` : '';
const regenBtn = !isUser && msgId ? `<button class="msg-action-btn" onclick="regenerateMessage('${msgId}')" title="Regenerate">Regen</button>` : '';
return `
<div class="message ${msg.role}">
<div class="message ${msg.role}" data-msg-id="${msgId}">
<div class="msg-inner">
<div class="msg-avatar">${isUser ? '👤' : '🤖'}</div>
<div class="msg-body">
<div class="msg-head">
<span class="msg-role">${isUser ? 'You' : esc(assistantLabel)}</span>
${branchNav}
${time ? `<span class="msg-time">${time}</span>` : ''}
<div class="msg-actions">
${editBtn}
${regenBtn}
<button class="msg-action-btn" onclick="UI.copyMessage(${index})" title="Copy">Copy</button>
</div>
</div>
@@ -137,6 +159,48 @@ const UI = {
</div>`;
},
showEditInline(messageId, currentContent) {
const msgEl = document.querySelector(`.message[data-msg-id="${messageId}"]`);
if (!msgEl) return;
const textEl = msgEl.querySelector('.msg-text');
if (!textEl) return;
// Replace message text with an edit form
const escaped = currentContent.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
textEl.innerHTML = `
<textarea class="msg-edit-input" id="editInput_${messageId}">${escaped}</textarea>
<div class="msg-edit-actions">
<button class="msg-edit-cancel" onclick="cancelEdit()">Cancel</button>
<button class="msg-edit-submit" onclick="submitEdit('${messageId}', document.getElementById('editInput_${messageId}').value)">Submit & Regenerate</button>
</div>`;
// Focus and select all
const textarea = document.getElementById(`editInput_${messageId}`);
if (textarea) {
textarea.focus();
textarea.selectionStart = textarea.value.length;
// Auto-resize
textarea.style.height = 'auto';
textarea.style.height = textarea.scrollHeight + 'px';
textarea.addEventListener('input', () => {
textarea.style.height = 'auto';
textarea.style.height = textarea.scrollHeight + 'px';
});
// Ctrl+Enter to submit
textarea.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
submitEdit(messageId, textarea.value);
}
if (e.key === 'Escape') {
e.preventDefault();
cancelEdit();
}
});
}
},
// ── Streaming ────────────────────────────
async streamResponse(response, messages, modelName) {
@@ -376,7 +440,8 @@ const UI = {
},
showRegenerate(show) {
document.getElementById('regenerateBtn').style.display = show ? 'flex' : 'none';
// Bottom-bar regenerate button removed in v0.7.1.
// Regen is now per-message via inline buttons.
},
// ── Toast ────────────────────────────────
@@ -414,9 +479,9 @@ const UI = {
UI.loadProfileIntoSettings();
UI.switchSettingsTab('general');
document.getElementById('settingsModal').classList.add('active');
openModal('settingsModal');
},
closeSettings() { document.getElementById('settingsModal').classList.remove('active'); },
closeSettings() { closeModal('settingsModal'); },
switchSettingsTab(tab) {
document.querySelectorAll('.settings-tab').forEach(t => t.classList.toggle('active', t.dataset.stab === tab));
@@ -473,6 +538,10 @@ const UI = {
if (splash) splash.style.zoom = z;
document.documentElement.style.setProperty('--msg-font', msgFont + 'px');
localStorage.setItem('cs-appearance', JSON.stringify({ scale, msgFont }));
// Recheck tab overflow after layout settles with new zoom
requestAnimationFrame(() => {
document.querySelectorAll('.modal-overlay.active .modal-tabs').forEach(checkTabsOverflow);
});
},
async checkUserProvidersAllowed() {
@@ -527,8 +596,8 @@ const UI = {
// ── Admin Modal ──────────────────────────
openAdmin() { document.getElementById('adminModal').classList.add('active'); UI.switchAdminTab('users'); },
closeAdmin() { document.getElementById('adminModal').classList.remove('active'); },
openAdmin() { openModal('adminModal'); UI.switchAdminTab('users'); },
closeAdmin() { closeModal('adminModal'); },
async switchAdminTab(tab) {
document.querySelectorAll('.admin-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));