package history import ( "context" "database/sql" "fmt" "strings" ) // This file owns the two derived per-session display fields carried in the session // list (GET /api/v1/sessions): the last-message preview and the unread count. Both are // read-only projections so a client can render a conversation row (with its unread // badge) without one request per session. The preview is truncated for display, and // neither field ever feeds identity or permission decisions — identity comes from // channel actor metadata / JWT (project red line). // lastMessagePreviewRunes bounds the preview content so a long message cannot bloat the // session-list response. A longer message is whitespace-collapsed and truncated to this // many runes with an ellipsis. const lastMessagePreviewRunes = 60 // MessageSummary is a display-only preview of a session's most recent message. type MessageSummary struct { Role string // "user" | "assistant" Content string // whitespace-collapsed, truncated preview CreatedAt int64 // Unix milliseconds } // listSessionsQuery reads every session plus its newest message and its unread count // in one statement. The LEFT JOIN's correlated subquery resolves each session's newest // row via the (session_id, created_at DESC) index (rowid DESC breaks a same-millisecond // tie by insertion order), so a session with no messages still appears with NULL preview // columns. The unread count is a correlated COUNT(*) over the same index // (session_id = s.id AND created_at > s.read_at is an equality + range scan), so no // per-session round trip (N+1) is needed and no extra index is required. Ordering floats // pinned sessions first, then keeps the main session before unpinned subs, then subs // oldest-first. const listSessionsQuery = `SELECT s.id, s.type, s.title, s.pinned, s.created_at, m.role, m.content, m.created_at, (SELECT COUNT(*) FROM chat_messages c WHERE c.session_id = s.id AND c.created_at > s.read_at) FROM chat_sessions s LEFT JOIN chat_messages m ON m.rowid = ( SELECT rowid FROM chat_messages WHERE session_id = s.id ORDER BY created_at DESC, rowid DESC LIMIT 1 ) ORDER BY s.pinned DESC, (s.type = ?) DESC, s.created_at ASC` // ListSessions returns pinned sessions first, then the main session, then every sub // session, oldest first, each carrying a LastMessage preview of its newest message (nil // when the session has none) and the UnreadCount derived from the session's read cursor. // Sessions, previews and unread counts are read in a single query, so the query count // does not grow with the number of sessions. The returned slice is non-nil. func (s *Store) ListSessions(ctx context.Context) ([]SessionMeta, error) { rows, err := s.db.QueryContext(ctx, listSessionsQuery, SessionMain) if err != nil { return nil, fmt.Errorf("history: list sessions: %w", err) } defer func() { _ = rows.Close() }() sessions := make([]SessionMeta, 0) for rows.Next() { meta, err := scanSessionMeta(rows) if err != nil { return nil, fmt.Errorf("history: scan session: %w", err) } sessions = append(sessions, meta) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("history: iterate sessions: %w", err) } return sessions, nil } // scanSessionMeta reads one joined chat_sessions + last-message + unread-count row. The // three message columns are NULL for a session that has never received a message, so they // scan into nullable holders and only produce a LastMessage when a row is present. The // unread count is a non-NULL COUNT(*), so it scans into a plain int. func scanSessionMeta(sc rowScanner) (SessionMeta, error) { var ( meta SessionMeta typeStr string pinned int role sql.NullString content sql.NullString lastAt sql.NullInt64 ) if err := sc.Scan(&meta.ID, &typeStr, &meta.Title, &pinned, &meta.CreatedAt, &role, &content, &lastAt, &meta.UnreadCount); err != nil { return SessionMeta{}, err } meta.Type = typeStr meta.Pinned = pinned != 0 if role.Valid && lastAt.Valid { meta.LastMessage = &MessageSummary{ Role: role.String, Content: previewContent(content.String), CreatedAt: lastAt.Int64, } } return meta, nil } // previewContent collapses whitespace runs (newlines included) to single spaces and // truncates to lastMessagePreviewRunes runes with an ellipsis. It is display-only. func previewContent(content string) string { joined := strings.Join(strings.Fields(content), " ") runes := []rune(joined) if len(runes) <= lastMessagePreviewRunes { return joined } return string(runes[:lastMessagePreviewRunes]) + "…" }