mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
co-authored by
Claude Sonnet 4.6
parent
60186b2489
commit
c23abc691f
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user