feat: add chat summary tab in the right sidebar and per-chat cost endpoint (#26649)

Stacked on #26657 (the persisted whole-chat summary backend). Base
branch is `chat-summary-62j9`; review/merge that first.

Adds a reusable `ChatSummary` component.

The summary text is the persisted whole-chat summary (`chat.summary`)
introduced by #26657. It is generated asynchronously and may be `null`
until the first summary is produced, in which case the popover renders a
muted empty state. Live updates arrive via that PR's
`chat_summary_change` watch event, which is already merged into the chat
caches.

Cost is served by a new per-chat endpoint, `GET
/api/experimental/chats/{chat}/cost`, which rolls up assistant-message
cost across a chat's root and child (subagent) chats and is authorized
like the other `{chat}` routes (read on the chat, 404 otherwise).

Visual and interaction coverage lives in `ChatSummary.stories.tsx` and
`ChatSummaryPopover.stories.tsx` (including populated-summary,
empty-state, and cost-loading cases).

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jaayden Halko
2026-07-27 10:05:05 +01:00
committed by GitHub
co-authored by Cursor
parent 88c7304e0b
commit 6f2011af88
38 changed files with 1744 additions and 87 deletions
+220 -2
View File
@@ -11683,7 +11683,7 @@ func assertChatCostSummary(t *testing.T, summary codersdk.ChatCostSummary, model
require.Equal(t, int64(1000), summary.TotalCostMicros)
require.Equal(t, int64(2), summary.PricedMessageCount)
require.Equal(t, int64(0), summary.UnpricedMessageCount)
require.Equal(t, int64(0), summary.UnpricedMessagesHavingUsageCount)
require.Equal(t, int64(200), summary.TotalInputTokens)
require.Equal(t, int64(100), summary.TotalOutputTokens)
require.Equal(t, int64(4000), summary.TotalRuntimeMs)
@@ -11792,6 +11792,224 @@ func TestChatCostSummary_AdminDrilldown(t *testing.T) {
})
}
func TestGetChatCost(t *testing.T) {
t.Parallel()
t.Run("BasicCost", func(t *testing.T) {
t.Parallel()
f := seedChatCostFixture(t)
ctx := testutil.Context(t, testutil.WaitLong)
cost, err := f.Client.GetChatCost(ctx, f.ChatID)
require.NoError(t, err)
require.Equal(t, f.ChatID, cost.ChatID)
require.Equal(t, int64(1000), cost.TotalCostMicros)
require.Equal(t, int64(2), cost.PricedMessageCount)
require.Equal(t, int64(0), cost.UnpricedMessagesHavingUsageCount)
})
t.Run("RollsUpSubtree", func(t *testing.T) {
t.Parallel()
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModelConfig(t, client)
rootChat := dbgen.Chat(t, db, database.Chat{
OrganizationID: firstUser.OrganizationID,
OwnerID: firstUser.UserID,
LastModelConfigID: modelConfig.ID,
Title: "root chat",
})
_ = dbgen.ChatMessage(t, db, database.ChatMessage{
ChatID: rootChat.ID,
ModelConfigID: uuid.NullUUID{UUID: modelConfig.ID, Valid: true},
Role: database.ChatMessageRoleAssistant,
TotalCostMicros: sql.NullInt64{Int64: 500, Valid: true},
})
childChat := dbgen.Chat(t, db, database.Chat{
OrganizationID: firstUser.OrganizationID,
OwnerID: firstUser.UserID,
LastModelConfigID: modelConfig.ID,
Title: "child chat",
ParentChatID: uuid.NullUUID{UUID: rootChat.ID, Valid: true},
RootChatID: uuid.NullUUID{UUID: rootChat.ID, Valid: true},
})
_ = dbgen.ChatMessage(t, db, database.ChatMessage{
ChatID: childChat.ID,
ModelConfigID: uuid.NullUUID{UUID: modelConfig.ID, Valid: true},
Role: database.ChatMessageRoleAssistant,
TotalCostMicros: sql.NullInt64{Int64: 250, Valid: true},
})
// root_chat_id is flattened to the top-level root at any depth,
// so subtree traversal must follow parent_chat_id instead.
grandchildChat := dbgen.Chat(t, db, database.Chat{
OrganizationID: firstUser.OrganizationID,
OwnerID: firstUser.UserID,
LastModelConfigID: modelConfig.ID,
Title: "grandchild chat",
ParentChatID: uuid.NullUUID{UUID: childChat.ID, Valid: true},
RootChatID: uuid.NullUUID{UUID: rootChat.ID, Valid: true},
})
_ = dbgen.ChatMessage(t, db, database.ChatMessage{
ChatID: grandchildChat.ID,
ModelConfigID: uuid.NullUUID{UUID: modelConfig.ID, Valid: true},
Role: database.ChatMessageRoleAssistant,
TotalCostMicros: sql.NullInt64{Int64: 100, Valid: true},
})
ctx := testutil.Context(t, testutil.WaitLong)
// The root rolls up every descendant's cost.
rootCost, err := client.GetChatCost(ctx, rootChat.ID)
require.NoError(t, err)
require.Equal(t, rootChat.ID, rootCost.ChatID)
require.Equal(t, int64(850), rootCost.TotalCostMicros)
require.Equal(t, int64(3), rootCost.PricedMessageCount)
// A subagent reports only its own subtree: itself plus the
// nested subagents it spawned, excluding the parent's spend.
childCost, err := client.GetChatCost(ctx, childChat.ID)
require.NoError(t, err)
require.Equal(t, childChat.ID, childCost.ChatID)
require.Equal(t, int64(350), childCost.TotalCostMicros)
require.Equal(t, int64(2), childCost.PricedMessageCount)
// A leaf subagent reports only its own spend.
grandchildCost, err := client.GetChatCost(ctx, grandchildChat.ID)
require.NoError(t, err)
require.Equal(t, grandchildChat.ID, grandchildCost.ChatID)
require.Equal(t, int64(100), grandchildCost.TotalCostMicros)
require.Equal(t, int64(1), grandchildCost.PricedMessageCount)
})
t.Run("UnpricedMessages", func(t *testing.T) {
t.Parallel()
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModelConfig(t, client)
chat := dbgen.Chat(t, db, database.Chat{
OrganizationID: firstUser.OrganizationID,
OwnerID: firstUser.UserID,
LastModelConfigID: modelConfig.ID,
Title: "unpriced chat",
})
_ = dbgen.ChatMessage(t, db, database.ChatMessage{
ChatID: chat.ID,
ModelConfigID: uuid.NullUUID{UUID: modelConfig.ID, Valid: true},
Role: database.ChatMessageRoleAssistant,
TotalCostMicros: sql.NullInt64{Int64: 400, Valid: true},
})
// Token usage but no cost (no model pricing) counts as unpriced.
_ = dbgen.ChatMessage(t, db, database.ChatMessage{
ChatID: chat.ID,
ModelConfigID: uuid.NullUUID{UUID: modelConfig.ID, Valid: true},
Role: database.ChatMessageRoleAssistant,
InputTokens: sql.NullInt64{Int64: 100, Valid: true},
OutputTokens: sql.NullInt64{Int64: 50, Valid: true},
})
ctx := testutil.Context(t, testutil.WaitLong)
cost, err := client.GetChatCost(ctx, chat.ID)
require.NoError(t, err)
require.Equal(t, int64(400), cost.TotalCostMicros)
require.Equal(t, int64(1), cost.PricedMessageCount)
require.Equal(t, int64(1), cost.UnpricedMessagesHavingUsageCount)
})
t.Run("MemberCannotReadOtherUsersChat", func(t *testing.T) {
t.Parallel()
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
modelConfig := createChatModelConfig(t, client)
chat := dbgen.Chat(t, db, database.Chat{
OrganizationID: firstUser.OrganizationID,
OwnerID: firstUser.UserID,
LastModelConfigID: modelConfig.ID,
Title: "owner chat",
})
ctx := testutil.Context(t, testutil.WaitLong)
_, err := memberClient.GetChatCost(ctx, chat.ID)
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
})
t.Run("ZeroMessages", func(t *testing.T) {
t.Parallel()
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModelConfig(t, client)
// No assistant messages must still return one zero-total row; a COALESCE
// or :one regression would surface as sql.ErrNoRows -> 500.
chat := dbgen.Chat(t, db, database.Chat{
OrganizationID: firstUser.OrganizationID,
OwnerID: firstUser.UserID,
LastModelConfigID: modelConfig.ID,
Title: "empty chat",
})
ctx := testutil.Context(t, testutil.WaitLong)
cost, err := client.GetChatCost(ctx, chat.ID)
require.NoError(t, err)
require.Equal(t, chat.ID, cost.ChatID)
require.Equal(t, int64(0), cost.TotalCostMicros)
require.Equal(t, int64(0), cost.PricedMessageCount)
require.Equal(t, int64(0), cost.UnpricedMessagesHavingUsageCount)
})
t.Run("ExcludesNonAssistantMessages", func(t *testing.T) {
t.Parallel()
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModelConfig(t, client)
chat := dbgen.Chat(t, db, database.Chat{
OrganizationID: firstUser.OrganizationID,
OwnerID: firstUser.UserID,
LastModelConfigID: modelConfig.ID,
Title: "mixed-role chat",
})
_ = dbgen.ChatMessage(t, db, database.ChatMessage{
ChatID: chat.ID,
ModelConfigID: uuid.NullUUID{UUID: modelConfig.ID, Valid: true},
Role: database.ChatMessageRoleAssistant,
TotalCostMicros: sql.NullInt64{Int64: 600, Valid: true},
})
// User-role cost must be excluded; the query bills only assistant messages.
_ = dbgen.ChatMessage(t, db, database.ChatMessage{
ChatID: chat.ID,
Role: database.ChatMessageRoleUser,
TotalCostMicros: sql.NullInt64{Int64: 999, Valid: true},
})
ctx := testutil.Context(t, testutil.WaitLong)
cost, err := client.GetChatCost(ctx, chat.ID)
require.NoError(t, err)
require.Equal(t, int64(600), cost.TotalCostMicros)
require.Equal(t, int64(1), cost.PricedMessageCount)
require.Equal(t, int64(0), cost.UnpricedMessagesHavingUsageCount)
})
}
func TestChatCostUsers(t *testing.T) {
t.Parallel()
@@ -11985,7 +12203,7 @@ func TestChatCostSummary_UnpricedMessages(t *testing.T) {
require.Equal(t, int64(500), summary.TotalCostMicros)
require.Equal(t, int64(1), summary.PricedMessageCount)
require.Equal(t, int64(1), summary.UnpricedMessageCount)
require.Equal(t, int64(1), summary.UnpricedMessagesHavingUsageCount)
require.Equal(t, int64(300), summary.TotalInputTokens)
require.Equal(t, int64(125), summary.TotalOutputTokens)
}