feat: sort AI sessions by last prompt time (#24440)

Previously, the sessions list sorted by `MIN(started_at)` across
interceptions, so sessions with old start times but recent activity
would sink to the bottom of the list regardless of how recently they
were used.

`ListAIBridgeSessions` now sorts by `COALESCE(MAX(prompt.created_at),
MIN(started_at)) DESC`, exposed as the non-nullable `last_active_at`
field. Sessions with prompts surface by last activity; sessions with no
prompts fall back to their start time.

The original implementation used two separate columns (`last_active_at`
as a nullable prompt timestamp and `sort_at` as the non-nullable cursor
key). This revision collapses them into a single `last_active_at` that
is always set — simplifying the SQL, the Go conversion, the API type,
and the frontend.

🤖 Generated with [Claude Code](https://claude.ai/claude-code)

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeremy Ruppel
2026-04-22 12:06:49 -04:00
committed by GitHub
co-authored by Claude Sonnet 4.6
parent 60186b2489
commit c23abc691f
14 changed files with 340 additions and 59 deletions
+6 -5
View File
@@ -1038,11 +1038,12 @@ func AIBridgeSession(row database.ListAIBridgeSessionsRow) codersdk.AIBridgeSess
Name: row.UserName,
AvatarURL: row.UserAvatarUrl,
}),
Providers: row.Providers,
Models: row.Models,
Metadata: jsonOrEmptyMap(pqtype.NullRawMessage{RawMessage: row.Metadata, Valid: len(row.Metadata) > 0}),
StartedAt: row.StartedAt,
Threads: row.Threads,
Providers: row.Providers,
Models: row.Models,
Metadata: jsonOrEmptyMap(pqtype.NullRawMessage{RawMessage: row.Metadata, Valid: len(row.Metadata) > 0}),
StartedAt: row.StartedAt,
Threads: row.Threads,
LastActiveAt: row.LastActiveAt,
TokenUsageSummary: codersdk.AIBridgeSessionTokenUsageSummary{
InputTokens: row.InputTokens,
OutputTokens: row.OutputTokens,
+1
View File
@@ -1040,6 +1040,7 @@ func (q *sqlQuerier) ListAuthorizedAIBridgeSessions(ctx context.Context, arg Lis
&i.CacheReadInputTokens,
&i.CacheWriteInputTokens,
&i.LastPrompt,
&i.LastActiveAt,
); err != nil {
return nil, err
}
+39 -16
View File
@@ -1364,22 +1364,43 @@ func (q *sqlQuerier) ListAIBridgeSessionThreads(ctx context.Context, arg ListAIB
const listAIBridgeSessions = `-- name: ListAIBridgeSessions :many
WITH cursor_pos AS (
-- Resolve the cursor's started_at once, outside the HAVING clause,
-- so the planner cannot accidentally re-evaluate it per group.
SELECT MIN(aibridge_interceptions.started_at) AS started_at
FROM aibridge_interceptions
WHERE aibridge_interceptions.session_id = $1 AND aibridge_interceptions.ended_at IS NOT NULL
-- Resolve the cursor's last_active_at once, outside the HAVING clause,
-- so the planner cannot accidentally re-evaluate it per group. Direct
-- LEFT JOIN is safe here since we only use MAX/MIN aggregates (no COUNT
-- affected by fan-out from multiple prompts per interception).
-- COALESCE falls back to MIN(ai.started_at) so the cursor value is
-- never NULL, which would silently drop rows from the HAVING comparison.
SELECT COALESCE(MAX(up.created_at), MIN(ai.started_at)) AS last_active_at
FROM aibridge_interceptions ai
LEFT JOIN aibridge_user_prompts up ON up.interception_id = ai.id
WHERE ai.session_id = $1 AND ai.ended_at IS NOT NULL
),
session_page AS (
-- Paginate at the session level first; only cheap aggregates here.
-- A lateral correlated subquery for prompts keeps the join one-to-one
-- with aibridge_interceptions so COUNT(*) for thread tallies is not
-- inflated. LIMIT 1 combined with the (interception_id, created_at DESC)
-- index makes this an index-only lookup per interception row rather than
-- a full-table-scan GROUP BY over all prompts.
-- last_active_at is the latest prompt timestamp, falling back to
-- MIN(started_at) for sessions with no prompts. The COALESCE ensures
-- it is never NULL so the HAVING row-value cursor comparison is safe.
SELECT
ai.session_id,
ai.initiator_id,
MIN(ai.started_at) AS started_at,
MAX(ai.ended_at) AS ended_at,
COUNT(*) FILTER (WHERE ai.thread_root_id IS NULL) AS threads
COUNT(*) FILTER (WHERE ai.thread_root_id IS NULL) AS threads,
COALESCE(MAX(latest_prompt.latest_prompt_at), MIN(ai.started_at))::timestamptz AS last_active_at
FROM
aibridge_interceptions ai
LEFT JOIN LATERAL (
SELECT created_at AS latest_prompt_at
FROM aibridge_user_prompts
WHERE interception_id = ai.id
ORDER BY created_at DESC
LIMIT 1
) latest_prompt ON true
WHERE
-- Remove inflight interceptions (ones which lack an ended_at value).
ai.ended_at IS NOT NULL
@@ -1422,22 +1443,21 @@ session_page AS (
GROUP BY
ai.session_id, ai.initiator_id
HAVING
-- Cursor pagination: uses a composite (started_at, session_id)
-- cursor to support keyset pagination. The less-than comparison
-- matches the DESC sort order so rows after the cursor come
-- later in results. The cursor value comes from cursor_pos to
-- guarantee single evaluation.
-- Cursor pagination: uses a composite (last_active_at, session_id) cursor to
-- support keyset pagination. The less-than comparison matches the DESC
-- sort order so rows after the cursor come later in results. The cursor
-- value comes from cursor_pos to guarantee single evaluation.
CASE
WHEN $1::text != '' THEN (
(MIN(ai.started_at), ai.session_id) < (
(SELECT started_at FROM cursor_pos),
(COALESCE(MAX(latest_prompt.latest_prompt_at), MIN(ai.started_at)), ai.session_id) < (
(SELECT last_active_at FROM cursor_pos),
$1::text
)
)
ELSE true
END
ORDER BY
MIN(ai.started_at) DESC,
last_active_at DESC,
ai.session_id DESC
LIMIT COALESCE(NULLIF($10::integer, 0), 100)
OFFSET $9
@@ -1459,7 +1479,8 @@ SELECT
COALESCE(st.output_tokens, 0)::bigint AS output_tokens,
COALESCE(st.cache_read_input_tokens, 0)::bigint AS cache_read_input_tokens,
COALESCE(st.cache_write_input_tokens, 0)::bigint AS cache_write_input_tokens,
COALESCE(slp.prompt, '') AS last_prompt
COALESCE(slp.prompt, '') AS last_prompt,
sp.last_active_at AS last_active_at
FROM
session_page sp
JOIN
@@ -1496,7 +1517,7 @@ LEFT JOIN LATERAL (
LIMIT 1
) slp ON true
ORDER BY
sp.started_at DESC,
sp.last_active_at DESC,
sp.session_id DESC
`
@@ -1531,6 +1552,7 @@ type ListAIBridgeSessionsRow struct {
CacheReadInputTokens int64 `db:"cache_read_input_tokens" json:"cache_read_input_tokens"`
CacheWriteInputTokens int64 `db:"cache_write_input_tokens" json:"cache_write_input_tokens"`
LastPrompt string `db:"last_prompt" json:"last_prompt"`
LastActiveAt time.Time `db:"last_active_at" json:"last_active_at"`
}
// Returns paginated sessions with aggregated metadata, token counts, and
@@ -1578,6 +1600,7 @@ func (q *sqlQuerier) ListAIBridgeSessions(ctx context.Context, arg ListAIBridgeS
&i.CacheReadInputTokens,
&i.CacheWriteInputTokens,
&i.LastPrompt,
&i.LastActiveAt,
); err != nil {
return nil, err
}
+37 -16
View File
@@ -446,22 +446,43 @@ WHERE
-- single GROUP BY scan, then do expensive lateral joins (tokens, prompts,
-- first-interception metadata) only for the ~page-size result set.
WITH cursor_pos AS (
-- Resolve the cursor's started_at once, outside the HAVING clause,
-- so the planner cannot accidentally re-evaluate it per group.
SELECT MIN(aibridge_interceptions.started_at) AS started_at
FROM aibridge_interceptions
WHERE aibridge_interceptions.session_id = @after_session_id AND aibridge_interceptions.ended_at IS NOT NULL
-- Resolve the cursor's last_active_at once, outside the HAVING clause,
-- so the planner cannot accidentally re-evaluate it per group. Direct
-- LEFT JOIN is safe here since we only use MAX/MIN aggregates (no COUNT
-- affected by fan-out from multiple prompts per interception).
-- COALESCE falls back to MIN(ai.started_at) so the cursor value is
-- never NULL, which would silently drop rows from the HAVING comparison.
SELECT COALESCE(MAX(up.created_at), MIN(ai.started_at)) AS last_active_at
FROM aibridge_interceptions ai
LEFT JOIN aibridge_user_prompts up ON up.interception_id = ai.id
WHERE ai.session_id = @after_session_id AND ai.ended_at IS NOT NULL
),
session_page AS (
-- Paginate at the session level first; only cheap aggregates here.
-- A lateral correlated subquery for prompts keeps the join one-to-one
-- with aibridge_interceptions so COUNT(*) for thread tallies is not
-- inflated. LIMIT 1 combined with the (interception_id, created_at DESC)
-- index makes this an index-only lookup per interception row rather than
-- a full-table-scan GROUP BY over all prompts.
-- last_active_at is the latest prompt timestamp, falling back to
-- MIN(started_at) for sessions with no prompts. The COALESCE ensures
-- it is never NULL so the HAVING row-value cursor comparison is safe.
SELECT
ai.session_id,
ai.initiator_id,
MIN(ai.started_at) AS started_at,
MAX(ai.ended_at) AS ended_at,
COUNT(*) FILTER (WHERE ai.thread_root_id IS NULL) AS threads
COUNT(*) FILTER (WHERE ai.thread_root_id IS NULL) AS threads,
COALESCE(MAX(latest_prompt.latest_prompt_at), MIN(ai.started_at))::timestamptz AS last_active_at
FROM
aibridge_interceptions ai
LEFT JOIN LATERAL (
SELECT created_at AS latest_prompt_at
FROM aibridge_user_prompts
WHERE interception_id = ai.id
ORDER BY created_at DESC
LIMIT 1
) latest_prompt ON true
WHERE
-- Remove inflight interceptions (ones which lack an ended_at value).
ai.ended_at IS NOT NULL
@@ -504,22 +525,21 @@ session_page AS (
GROUP BY
ai.session_id, ai.initiator_id
HAVING
-- Cursor pagination: uses a composite (started_at, session_id)
-- cursor to support keyset pagination. The less-than comparison
-- matches the DESC sort order so rows after the cursor come
-- later in results. The cursor value comes from cursor_pos to
-- guarantee single evaluation.
-- Cursor pagination: uses a composite (last_active_at, session_id) cursor to
-- support keyset pagination. The less-than comparison matches the DESC
-- sort order so rows after the cursor come later in results. The cursor
-- value comes from cursor_pos to guarantee single evaluation.
CASE
WHEN @after_session_id::text != '' THEN (
(MIN(ai.started_at), ai.session_id) < (
(SELECT started_at FROM cursor_pos),
(COALESCE(MAX(latest_prompt.latest_prompt_at), MIN(ai.started_at)), ai.session_id) < (
(SELECT last_active_at FROM cursor_pos),
@after_session_id::text
)
)
ELSE true
END
ORDER BY
MIN(ai.started_at) DESC,
last_active_at DESC,
ai.session_id DESC
LIMIT COALESCE(NULLIF(@limit_::integer, 0), 100)
OFFSET @offset_
@@ -541,7 +561,8 @@ SELECT
COALESCE(st.output_tokens, 0)::bigint AS output_tokens,
COALESCE(st.cache_read_input_tokens, 0)::bigint AS cache_read_input_tokens,
COALESCE(st.cache_write_input_tokens, 0)::bigint AS cache_write_input_tokens,
COALESCE(slp.prompt, '') AS last_prompt
COALESCE(slp.prompt, '') AS last_prompt,
sp.last_active_at AS last_active_at
FROM
session_page sp
JOIN
@@ -578,7 +599,7 @@ LEFT JOIN LATERAL (
LIMIT 1
) slp ON true
ORDER BY
sp.started_at DESC,
sp.last_active_at DESC,
sp.session_id DESC
;