package history import ( "context" "database/sql" "fmt" "strings" ) // This file owns the last-message preview carried in the session list // (GET /api/v1/sessions): a display-only summary of each session's newest message so a // client can render a conversation row without one request per session. The preview is // truncated for display and never 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 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. 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 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). Sessions and previews 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 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. 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); 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]) + "…" }