From 6f2011af888369366d667982a119f4f8fe1b9b9f Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Mon, 27 Jul 2026 17:05:05 +0800 Subject: [PATCH] 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 --- coderd/apidoc/docs.go | 54 +++++ coderd/apidoc/swagger.json | 50 ++++ coderd/coderd.go | 1 + coderd/database/dbauthz/dbauthz.go | 7 + coderd/database/dbauthz/dbauthz_test.go | 17 +- coderd/database/dbmetrics/querymetrics.go | 8 + coderd/database/dbmock/dbmock.go | 15 ++ coderd/database/querier.go | 5 + coderd/database/queries.sql.go | 81 ++++++- coderd/database/queries/chats.sql | 43 +++- coderd/exp_chats.go | 65 ++++- coderd/exp_chats_internal_test.go | 84 +++++++ coderd/exp_chats_test.go | 222 +++++++++++++++++- coderd/x/chatd/chatd.go | 65 ++++- coderd/x/chatd/chatd_internal_test.go | 137 +++++++++++ coderd/x/chatd/generation_preparer.go | 4 +- coderd/x/chatd/quickgen.go | 140 +++++++++++ coderd/x/chatd/summarygen_internal_test.go | 84 +++++++ coderd/x/chatd/turn_summary_internal_test.go | 20 +- codersdk/chats.go | 51 +++- docs/reference/api/chats.md | 42 ++++ docs/reference/api/schemas.md | 20 ++ site/src/api/api.ts | 6 + site/src/api/queries/chats.test.ts | 17 ++ site/src/api/queries/chats.ts | 13 + site/src/api/typesGenerated.ts | 16 +- .../SpendPage/SpendPageView.stories.tsx | 2 +- .../ChatCostSummaryView.stories.tsx | 6 +- .../components/ChatCostSummaryView.tsx | 12 +- .../AgentsPage/AgentChatPageView.stories.tsx | 45 ++-- .../pages/AgentsPage/AgentChatPageView.tsx | 9 + .../AgentsPage/AgentsPageLayout.stories.tsx | 2 +- .../pages/AgentsPage/AgentsPageLayout.test.ts | 83 ++++++- .../src/pages/AgentsPage/AgentsPageLayout.tsx | 39 +++ .../components/ChatSummary.stories.tsx | 105 +++++++++ .../AgentsPage/components/ChatSummary.tsx | 95 ++++++++ .../components/ChatSummaryPanel.stories.tsx | 119 ++++++++++ .../components/ChatSummaryPanel.tsx | 47 ++++ 38 files changed, 1744 insertions(+), 87 deletions(-) create mode 100644 site/src/pages/AgentsPage/components/ChatSummary.stories.tsx create mode 100644 site/src/pages/AgentsPage/components/ChatSummary.tsx create mode 100644 site/src/pages/AgentsPage/components/ChatSummaryPanel.stories.tsx create mode 100644 site/src/pages/AgentsPage/components/ChatSummaryPanel.tsx diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 6a906f903f..3f90aa8c1e 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -580,6 +580,42 @@ const docTemplate = `{ ] } }, + "/api/experimental/chats/{chat}/cost": { + "get": { + "description": "Experimental: this endpoint is subject to change.", + "produces": [ + "application/json" + ], + "tags": [ + "Chats" + ], + "summary": "Get chat cost", + "operationId": "get-chat-cost", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Chat ID", + "name": "chat", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.ChatCost" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, "/api/experimental/chats/{chat}/diff": { "get": { "description": "Experimental: this endpoint is subject to change.", @@ -17271,6 +17307,24 @@ const docTemplate = `{ } } }, + "codersdk.ChatCost": { + "type": "object", + "properties": { + "chat_id": { + "type": "string", + "format": "uuid" + }, + "priced_message_count": { + "type": "integer" + }, + "total_cost_micros": { + "type": "integer" + }, + "unpriced_messages_having_usage_count": { + "type": "integer" + } + } + }, "codersdk.ChatDiffContents": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 283dcea72e..b958b0e4a2 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -509,6 +509,38 @@ ] } }, + "/api/experimental/chats/{chat}/cost": { + "get": { + "description": "Experimental: this endpoint is subject to change.", + "produces": ["application/json"], + "tags": ["Chats"], + "summary": "Get chat cost", + "operationId": "get-chat-cost", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Chat ID", + "name": "chat", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.ChatCost" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, "/api/experimental/chats/{chat}/diff": { "get": { "description": "Experimental: this endpoint is subject to change.", @@ -15523,6 +15555,24 @@ } } }, + "codersdk.ChatCost": { + "type": "object", + "properties": { + "chat_id": { + "type": "string", + "format": "uuid" + }, + "priced_message_count": { + "type": "integer" + }, + "total_cost_micros": { + "type": "integer" + }, + "unpriced_messages_having_usage_count": { + "type": "integer" + } + } + }, "codersdk.ChatDiffContents": { "type": "object", "properties": { diff --git a/coderd/coderd.go b/coderd/coderd.go index 1eae3544b9..158af33929 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1420,6 +1420,7 @@ func New(options *Options) *API { }) r.Get("/", api.getChat) r.Patch("/", api.patchChat) + r.Get("/cost", api.getChatCost) r.Get("/messages", api.getChatMessages) r.Post("/messages", api.postChatMessages) r.Patch("/messages/{message}", api.patchChatMessage) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index b35529b028..578fb3ab18 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -3488,6 +3488,13 @@ func (q *querier) GetChatModelConfigsForTelemetry(ctx context.Context) ([]databa return q.db.GetChatModelConfigsForTelemetry(ctx) } +func (q *querier) GetChatModelUsageCostByChatID(ctx context.Context, chatID uuid.UUID) (database.GetChatModelUsageCostByChatIDRow, error) { + if _, err := q.GetChatByID(ctx, chatID); err != nil { + return database.GetChatModelUsageCostByChatIDRow{}, err + } + return q.db.GetChatModelUsageCostByChatID(ctx, chatID) +} + func (q *querier) GetChatPersonalModelOverridesEnabled(ctx context.Context) (bool, error) { // The personal model overrides flag is a deployment-wide setting read by // authenticated chat users. We only require that an explicit actor is diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index d06e4e5c5e..8f98fd7d7b 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -938,11 +938,11 @@ func (s *MethodTestSuite) TestChats() { EndDate: time.Date(2025, 2, 1, 0, 0, 0, 0, time.UTC), } row := database.GetChatCostSummaryRow{ - TotalCostMicros: 987, - PricedMessageCount: 12, - UnpricedMessageCount: 2, - TotalInputTokens: 400, - TotalOutputTokens: 800, + TotalCostMicros: 987, + PricedMessageCount: 12, + UnpricedMessagesHavingUsageCount: 2, + TotalInputTokens: 400, + TotalOutputTokens: 800, } dbm.EXPECT().GetChatCostSummary(gomock.Any(), arg).Return(row, nil).AnyTimes() check.Args(arg).Asserts(rbac.ResourceChat.WithOwner(arg.OwnerID.String()).AnyOrganization(), policy.ActionRead).Returns(row) @@ -1069,6 +1069,13 @@ func (s *MethodTestSuite) TestChats() { dbm.EXPECT().GetChatMessagesByChatID(gomock.Any(), arg).Return(msgs, nil).AnyTimes() check.Args(arg).Asserts(chat, policy.ActionRead).Returns(msgs) })) + s.Run("GetChatModelUsageCostByChatID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + chat := testutil.Fake(s.T(), faker, database.Chat{}) + row := database.GetChatModelUsageCostByChatIDRow{ChatID: chat.ID, TotalCostMicros: 1000, PricedMessageCount: 2} + dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() + dbm.EXPECT().GetChatModelUsageCostByChatID(gomock.Any(), chat.ID).Return(row, nil).AnyTimes() + check.Args(chat.ID).Asserts(chat, policy.ActionRead).Returns(row) + })) s.Run("GetChatMessagesByChatIDAscPaginated", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { chat := testutil.Fake(s.T(), faker, database.Chat{}) msgs := []database.ChatMessage{testutil.Fake(s.T(), faker, database.ChatMessage{ChatID: chat.ID})} diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 37d8cc9641..07d3321747 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -1713,6 +1713,14 @@ func (m queryMetricsStore) GetChatModelConfigsForTelemetry(ctx context.Context) return r0, r1 } +func (m queryMetricsStore) GetChatModelUsageCostByChatID(ctx context.Context, chatID uuid.UUID) (database.GetChatModelUsageCostByChatIDRow, error) { + start := time.Now() + r0, r1 := m.s.GetChatModelUsageCostByChatID(ctx, chatID) + m.queryLatencies.WithLabelValues("GetChatModelUsageCostByChatID").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatModelUsageCostByChatID").Inc() + return r0, r1 +} + func (m queryMetricsStore) GetChatPersonalModelOverridesEnabled(ctx context.Context) (bool, error) { start := time.Now() r0, r1 := m.s.GetChatPersonalModelOverridesEnabled(ctx) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 845b3c279d..9a255e2df7 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -3163,6 +3163,21 @@ func (mr *MockStoreMockRecorder) GetChatModelConfigsForTelemetry(ctx any) *gomoc return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatModelConfigsForTelemetry", reflect.TypeOf((*MockStore)(nil).GetChatModelConfigsForTelemetry), ctx) } +// GetChatModelUsageCostByChatID mocks base method. +func (m *MockStore) GetChatModelUsageCostByChatID(ctx context.Context, chatID uuid.UUID) (database.GetChatModelUsageCostByChatIDRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetChatModelUsageCostByChatID", ctx, chatID) + ret0, _ := ret[0].(database.GetChatModelUsageCostByChatIDRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetChatModelUsageCostByChatID indicates an expected call of GetChatModelUsageCostByChatID. +func (mr *MockStoreMockRecorder) GetChatModelUsageCostByChatID(ctx, chatID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatModelUsageCostByChatID", reflect.TypeOf((*MockStore)(nil).GetChatModelUsageCostByChatID), ctx, chatID) +} + // GetChatPersonalModelOverridesEnabled mocks base method. func (m *MockStore) GetChatPersonalModelOverridesEnabled(ctx context.Context) (bool, error) { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index c637c654c7..7579a25c7d 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -456,6 +456,11 @@ type sqlcQuerier interface { // Returns all model configurations for telemetry snapshot collection. // deleted = false guarantees ai_provider_id is non-null, so INNER JOIN is safe. GetChatModelConfigsForTelemetry(ctx context.Context) ([]GetChatModelConfigsForTelemetryRow, error) + // Assistant-message cost rolled up over the requested chat's subtree: the + // chat itself plus every descendant reachable through parent_chat_id. A + // root chat therefore reports its whole tree, while a subagent chat + // reports only its own spend plus any nested subagents it spawned. + GetChatModelUsageCostByChatID(ctx context.Context, chatID uuid.UUID) (GetChatModelUsageCostByChatIDRow, error) // GetChatPersonalModelOverridesEnabled returns whether users may configure // personal chat model overrides. It defaults to false when unset. GetChatPersonalModelOverridesEnabled(ctx context.Context) (bool, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index b55924928a..f4ae41eb3c 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -7557,7 +7557,7 @@ SELECT OR cm.cache_creation_tokens IS NOT NULL OR cm.cache_read_tokens IS NOT NULL ) - )::bigint AS unpriced_message_count, + )::bigint AS unpriced_messages_having_usage_count, COALESCE(SUM(cm.input_tokens), 0)::bigint AS total_input_tokens, COALESCE(SUM(cm.output_tokens), 0)::bigint AS total_output_tokens, COALESCE(SUM(cm.cache_read_tokens), 0)::bigint AS total_cache_read_tokens, @@ -7581,14 +7581,14 @@ type GetChatCostSummaryParams struct { } type GetChatCostSummaryRow struct { - TotalCostMicros int64 `db:"total_cost_micros" json:"total_cost_micros"` - PricedMessageCount int64 `db:"priced_message_count" json:"priced_message_count"` - UnpricedMessageCount int64 `db:"unpriced_message_count" json:"unpriced_message_count"` - TotalInputTokens int64 `db:"total_input_tokens" json:"total_input_tokens"` - TotalOutputTokens int64 `db:"total_output_tokens" json:"total_output_tokens"` - TotalCacheReadTokens int64 `db:"total_cache_read_tokens" json:"total_cache_read_tokens"` - TotalCacheCreationTokens int64 `db:"total_cache_creation_tokens" json:"total_cache_creation_tokens"` - TotalRuntimeMs int64 `db:"total_runtime_ms" json:"total_runtime_ms"` + TotalCostMicros int64 `db:"total_cost_micros" json:"total_cost_micros"` + PricedMessageCount int64 `db:"priced_message_count" json:"priced_message_count"` + UnpricedMessagesHavingUsageCount int64 `db:"unpriced_messages_having_usage_count" json:"unpriced_messages_having_usage_count"` + TotalInputTokens int64 `db:"total_input_tokens" json:"total_input_tokens"` + TotalOutputTokens int64 `db:"total_output_tokens" json:"total_output_tokens"` + TotalCacheReadTokens int64 `db:"total_cache_read_tokens" json:"total_cache_read_tokens"` + TotalCacheCreationTokens int64 `db:"total_cache_creation_tokens" json:"total_cache_creation_tokens"` + TotalRuntimeMs int64 `db:"total_runtime_ms" json:"total_runtime_ms"` } // Aggregate cost summary for a single user within a date range. @@ -7599,7 +7599,7 @@ func (q *sqlQuerier) GetChatCostSummary(ctx context.Context, arg GetChatCostSumm err := row.Scan( &i.TotalCostMicros, &i.PricedMessageCount, - &i.UnpricedMessageCount, + &i.UnpricedMessagesHavingUsageCount, &i.TotalInputTokens, &i.TotalOutputTokens, &i.TotalCacheReadTokens, @@ -8363,6 +8363,67 @@ func (q *sqlQuerier) GetChatModelConfigsForTelemetry(ctx context.Context) ([]Get return items, nil } +const getChatModelUsageCostByChatID = `-- name: GetChatModelUsageCostByChatID :one +WITH RECURSIVE target AS ( + SELECT $1::uuid AS chat_id +), subtree AS ( + SELECT chat_id AS id FROM target + UNION ALL + SELECT c.id + FROM chats c + JOIN subtree s ON c.parent_chat_id = s.id +), costs AS ( + SELECT + COALESCE(SUM(cm.total_cost_micros), 0)::bigint AS total_cost_micros, + COUNT(*) FILTER ( + WHERE cm.total_cost_micros IS NOT NULL + )::bigint AS priced_message_count, + COUNT(*) FILTER ( + WHERE cm.total_cost_micros IS NULL + AND ( + cm.input_tokens IS NOT NULL + OR cm.output_tokens IS NOT NULL + OR cm.reasoning_tokens IS NOT NULL + OR cm.cache_creation_tokens IS NOT NULL + OR cm.cache_read_tokens IS NOT NULL + ) + )::bigint AS unpriced_messages_having_usage_count + FROM chat_messages cm + JOIN subtree s ON s.id = cm.chat_id + WHERE cm.role = 'assistant' +) +SELECT + t.chat_id, + costs.total_cost_micros, + costs.priced_message_count, + costs.unpriced_messages_having_usage_count +FROM target t +CROSS JOIN costs +` + +type GetChatModelUsageCostByChatIDRow struct { + ChatID uuid.UUID `db:"chat_id" json:"chat_id"` + TotalCostMicros int64 `db:"total_cost_micros" json:"total_cost_micros"` + PricedMessageCount int64 `db:"priced_message_count" json:"priced_message_count"` + UnpricedMessagesHavingUsageCount int64 `db:"unpriced_messages_having_usage_count" json:"unpriced_messages_having_usage_count"` +} + +// Assistant-message cost rolled up over the requested chat's subtree: the +// chat itself plus every descendant reachable through parent_chat_id. A +// root chat therefore reports its whole tree, while a subagent chat +// reports only its own spend plus any nested subagents it spawned. +func (q *sqlQuerier) GetChatModelUsageCostByChatID(ctx context.Context, chatID uuid.UUID) (GetChatModelUsageCostByChatIDRow, error) { + row := q.db.QueryRowContext(ctx, getChatModelUsageCostByChatID, chatID) + var i GetChatModelUsageCostByChatIDRow + err := row.Scan( + &i.ChatID, + &i.TotalCostMicros, + &i.PricedMessageCount, + &i.UnpricedMessagesHavingUsageCount, + ) + return i, err +} + const getChatQueuedMessageByID = `-- name: GetChatQueuedMessageByID :one SELECT id, chat_id, content, created_at, model_config_id, position, created_by, reasoning_effort FROM chat_queued_messages WHERE id = $1::bigint AND chat_id = $2::uuid diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index 2d7389630f..836d1200b3 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -2198,7 +2198,7 @@ SELECT OR cm.cache_creation_tokens IS NOT NULL OR cm.cache_read_tokens IS NOT NULL ) - )::bigint AS unpriced_message_count, + )::bigint AS unpriced_messages_having_usage_count, COALESCE(SUM(cm.input_tokens), 0)::bigint AS total_input_tokens, COALESCE(SUM(cm.output_tokens), 0)::bigint AS total_output_tokens, COALESCE(SUM(cm.cache_read_tokens), 0)::bigint AS total_cache_read_tokens, @@ -2295,6 +2295,47 @@ FROM chat_costs cc LEFT JOIN chats rc ON rc.id = cc.root_chat_id ORDER BY cc.total_cost_micros DESC; +-- name: GetChatModelUsageCostByChatID :one +-- Assistant-message cost rolled up over the requested chat's subtree: the +-- chat itself plus every descendant reachable through parent_chat_id. A +-- root chat therefore reports its whole tree, while a subagent chat +-- reports only its own spend plus any nested subagents it spawned. +WITH RECURSIVE target AS ( + SELECT @chat_id::uuid AS chat_id +), subtree AS ( + SELECT chat_id AS id FROM target + UNION ALL + SELECT c.id + FROM chats c + JOIN subtree s ON c.parent_chat_id = s.id +), costs AS ( + SELECT + COALESCE(SUM(cm.total_cost_micros), 0)::bigint AS total_cost_micros, + COUNT(*) FILTER ( + WHERE cm.total_cost_micros IS NOT NULL + )::bigint AS priced_message_count, + COUNT(*) FILTER ( + WHERE cm.total_cost_micros IS NULL + AND ( + cm.input_tokens IS NOT NULL + OR cm.output_tokens IS NOT NULL + OR cm.reasoning_tokens IS NOT NULL + OR cm.cache_creation_tokens IS NOT NULL + OR cm.cache_read_tokens IS NOT NULL + ) + )::bigint AS unpriced_messages_having_usage_count + FROM chat_messages cm + JOIN subtree s ON s.id = cm.chat_id + WHERE cm.role = 'assistant' +) +SELECT + t.chat_id, + costs.total_cost_micros, + costs.priced_message_count, + costs.unpriced_messages_having_usage_count +FROM target t +CROSS JOIN costs; + -- name: GetChatCostPerUser :many -- Deployment-wide per-user cost rollup within a date range. -- Only counts assistant-role messages. diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index aa95221609..35ca899bac 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -1649,18 +1649,18 @@ func (api *API) chatCostSummary(rw http.ResponseWriter, r *http.Request) { } response := codersdk.ChatCostSummary{ - StartDate: startDate, - EndDate: endDate, - TotalCostMicros: summary.TotalCostMicros, - PricedMessageCount: summary.PricedMessageCount, - UnpricedMessageCount: summary.UnpricedMessageCount, - TotalInputTokens: summary.TotalInputTokens, - TotalOutputTokens: summary.TotalOutputTokens, - TotalCacheReadTokens: summary.TotalCacheReadTokens, - TotalCacheCreationTokens: summary.TotalCacheCreationTokens, - TotalRuntimeMs: summary.TotalRuntimeMs, - ByModel: modelBreakdowns, - ByChat: chatBreakdowns, + StartDate: startDate, + EndDate: endDate, + TotalCostMicros: summary.TotalCostMicros, + PricedMessageCount: summary.PricedMessageCount, + UnpricedMessagesHavingUsageCount: summary.UnpricedMessagesHavingUsageCount, + TotalInputTokens: summary.TotalInputTokens, + TotalOutputTokens: summary.TotalOutputTokens, + TotalCacheReadTokens: summary.TotalCacheReadTokens, + TotalCacheCreationTokens: summary.TotalCacheCreationTokens, + TotalRuntimeMs: summary.TotalRuntimeMs, + ByModel: modelBreakdowns, + ByChat: chatBreakdowns, } if usageStatus != nil { response.UsageLimit = usageStatus @@ -2411,6 +2411,47 @@ func (api *API) getChatMessages(rw http.ResponseWriter, r *http.Request) { }) } +// EXPERIMENTAL: this endpoint is experimental and is subject to change. +// +// @Summary Get chat cost +// @ID get-chat-cost +// @Security CoderSessionToken +// @Tags Chats +// @Produce json +// @Param chat path string true "Chat ID" format(uuid) +// @Success 200 {object} codersdk.ChatCost +// @Router /api/experimental/chats/{chat}/cost [get] +// @Description Experimental: this endpoint is subject to change. +// +//nolint:revive // HTTP handler writes to ResponseWriter. +func (api *API) getChatCost(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + chat := httpmw.ChatParam(r) + + // The query rolls up the requested chat's subtree, so a root chat + // reports itself plus all subagents while a subagent reports only + // its own spend (plus any nested subagents it spawned). + row, err := api.Database.GetChatModelUsageCostByChatID(ctx, chat.ID) + if err != nil { + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to get chat cost.", + Detail: err.Error(), + }) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChatCost{ + ChatID: row.ChatID, + TotalCostMicros: row.TotalCostMicros, + PricedMessageCount: row.PricedMessageCount, + UnpricedMessagesHavingUsageCount: row.UnpricedMessagesHavingUsageCount, + }) +} + // @Summary List chat user prompts // @ID list-chat-user-prompts // @Security CoderSessionToken diff --git a/coderd/exp_chats_internal_test.go b/coderd/exp_chats_internal_test.go index 1facd9ff97..bcd7624438 100644 --- a/coderd/exp_chats_internal_test.go +++ b/coderd/exp_chats_internal_test.go @@ -1,8 +1,13 @@ package coderd import ( + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" "testing" + "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" @@ -11,11 +16,90 @@ import ( "cdr.dev/slog/v3" "cdr.dev/slog/v3/sloggers/slogtest" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbmock" + "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" ) +// ExtractChatParam authorizes the read, then GetChatModelUsageCostByChatID +// authorizes it again. A denial on the second check means the ACL changed in +// between (a read-authz race). Assert it surfaces as 404, not 500. +func TestGetChatCostSurfacesReadAuthzRace(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + dbm := dbmock.NewMockStore(ctrl) + chat := database.Chat{ + ID: uuid.New(), + OrganizationID: uuid.New(), + OwnerID: uuid.New(), + } + + dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil) + dbm.EXPECT().GetChatModelUsageCostByChatID(gomock.Any(), chat.ID).Return( + database.GetChatModelUsageCostByChatIDRow{}, + dbauthz.NotAuthorizedError{Err: sql.ErrNoRows}, + ) + + api := &API{Options: &Options{Database: dbm}} + rtr := chi.NewRouter() + rtr.With(httpmw.ExtractChatParam(dbm)).Get("/chats/{chat}/cost", api.getChatCost) + + req := httptest.NewRequest(http.MethodGet, "/chats/"+chat.ID.String()+"/cost", nil) + rec := httptest.NewRecorder() + rtr.ServeHTTP(rec, req) + resp := rec.Result() + defer resp.Body.Close() + + require.Equal(t, http.StatusNotFound, resp.StatusCode) +} + +// A subagent chat's cost is scoped to its own subtree, so the handler +// must query the requested chat ID rather than resolving to the root. +func TestGetChatCostQueriesRequestedChat(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + dbm := dbmock.NewMockStore(ctrl) + rootID := uuid.New() + child := database.Chat{ + ID: uuid.New(), + OrganizationID: uuid.New(), + OwnerID: uuid.New(), + ParentChatID: uuid.NullUUID{UUID: rootID, Valid: true}, + RootChatID: uuid.NullUUID{UUID: rootID, Valid: true}, + } + + dbm.EXPECT().GetChatByID(gomock.Any(), child.ID).Return(child, nil) + dbm.EXPECT().GetChatModelUsageCostByChatID(gomock.Any(), child.ID).Return( + database.GetChatModelUsageCostByChatIDRow{ + ChatID: child.ID, + TotalCostMicros: 250, + PricedMessageCount: 1, + }, + nil, + ) + + api := &API{Options: &Options{Database: dbm}} + rtr := chi.NewRouter() + rtr.With(httpmw.ExtractChatParam(dbm)).Get("/chats/{chat}/cost", api.getChatCost) + + req := httptest.NewRequest(http.MethodGet, "/chats/"+child.ID.String()+"/cost", nil) + rec := httptest.NewRecorder() + rtr.ServeHTTP(rec, req) + resp := rec.Result() + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + var cost codersdk.ChatCost + require.NoError(t, json.NewDecoder(resp.Body).Decode(&cost)) + require.Equal(t, child.ID, cost.ChatID) + require.Equal(t, int64(250), cost.TotalCostMicros) + require.Equal(t, int64(1), cost.PricedMessageCount) +} + func TestEnrichMissingChatAgentIDs(t *testing.T) { t.Parallel() newAPI := func(t *testing.T) (*API, *dbmock.MockStore) { diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 09d934b341..d4762af6c3 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -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) } diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 6ca6fa56db..59072a6e0a 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -4381,6 +4381,12 @@ func (p *Server) maybeFinalizeTurnStatusLabelAndPush( logger slog.Logger, ) { if chat.ParentChatID.Valid { + // Subagent chats skip turn status labels and generated + // summaries, but a successful turn's final report doubles as + // the chat summary so subagents are not summary-less. + if status == database.ChatStatusWaiting { + p.storeSubagentReportSummaryAsync(ctx, chat, logger) + } return } @@ -4505,17 +4511,6 @@ func (p *Server) dispatchSuccessfulTurnPush( p.dispatchPush(ctx, chat, pushBody, database.ChatStatusWaiting, logger) } -func (p *Server) maybeClearLastTurnSummaryAsync( - ctx context.Context, - chat database.Chat, - logger slog.Logger, -) { - if chat.ParentChatID.Valid { - return - } - p.clearLastTurnSummaryAsync(ctx, chat, logger) -} - func (p *Server) setLastTurnSummaryAsync( ctx context.Context, chat database.Chat, @@ -4624,6 +4619,15 @@ const ( chatSummaryWorkTimeout = 120 * time.Second chatSummaryGenerateTimeout = 60 * time.Second chatSummaryWriteTimeout = 5 * time.Second + + // Subagent summaries reuse the final report instead of generating + // text, so their work timeout only covers two database round trips. + subagentReportSummaryTimeout = 15 * time.Second + // Bound the extracted report snippet near the 1-3 sentence + // generated summaries that root chats get, so subagent and parent + // summary panels read the same. + subagentReportSummaryMaxRunes = 300 + subagentReportSummaryMaxSentences = 3 ) // maybeGenerateChatSummaryAsync launches best-effort whole-chat summary @@ -4809,6 +4813,45 @@ func (p *Server) updateChatSummary( p.publishChatPubsubEvent(updatedChat, codersdk.ChatWatchEventKindChatSummaryChange, nil) } +func (p *Server) storeSubagentReportSummaryAsync( + ctx context.Context, + chat database.Chat, + logger slog.Logger, +) { + summaryCtx, stopSummaryCtx := p.inflightContext(ctx) + if err := p.goInflight(func() { + defer stopSummaryCtx() + p.storeSubagentReportSummary(summaryCtx, chat, logger) + }); err != nil { + stopSummaryCtx() + logger.Debug(context.WithoutCancel(ctx), "skipped subagent report summary", + slog.F("chat_id", chat.ID), slog.Error(err)) + } +} + +func (p *Server) storeSubagentReportSummary( + ctx context.Context, + chat database.Chat, + logger slog.Logger, +) { + ctx, cancel := context.WithTimeout(ctx, subagentReportSummaryTimeout) + defer cancel() + + //nolint:gocritic // Narrow daemon access for best-effort summary writes. + authCtx := dbauthz.AsChatd(ctx) + report, err := latestSubagentAssistantMessage(authCtx, p.db, chat.ID) + if err != nil { + logger.Debug(ctx, "failed to load subagent report for summary", + slog.F("chat_id", chat.ID), slog.Error(err)) + return + } + summary := subagentReportSummarySnippet(report) + if summary == "" { + return + } + p.updateChatSummary(ctx, logger, chat, chat.HistoryVersion, summary) +} + func (p *Server) webpushConfigured() bool { return p.webpushDispatcher != nil && p.webpushDispatcher.PublicKey() != "" } diff --git a/coderd/x/chatd/chatd_internal_test.go b/coderd/x/chatd/chatd_internal_test.go index f5c7b5c633..15bb8df0f4 100644 --- a/coderd/x/chatd/chatd_internal_test.go +++ b/coderd/x/chatd/chatd_internal_test.go @@ -14,6 +14,7 @@ import ( "charm.land/fantasy" "github.com/google/uuid" + "github.com/sqlc-dev/pqtype" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" "golang.org/x/xerrors" @@ -161,6 +162,142 @@ func TestUpdateChatSummary(t *testing.T) { }) } +func assistantReportMessage(t *testing.T, chatID uuid.UUID, id int64, text string) database.ChatMessage { + t.Helper() + + parts := []codersdk.ChatMessagePart{codersdk.ChatMessageText(text)} + data, err := json.Marshal(parts) + require.NoError(t, err) + + return database.ChatMessage{ + ID: id, + ChatID: chatID, + Role: database.ChatMessageRoleAssistant, + Content: pqtype.NullRawMessage{RawMessage: data, Valid: true}, + ContentVersion: chatprompt.ContentVersionV1, + Visibility: database.ChatMessageVisibilityBoth, + } +} + +func TestStoreSubagentReportSummary(t *testing.T) { + t.Parallel() + + t.Run("PersistsFinalReport", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + server := &Server{db: db} + chat := database.Chat{ + ID: uuid.New(), + OwnerID: uuid.New(), + ParentChatID: uuid.NullUUID{UUID: uuid.New(), Valid: true}, + HistoryVersion: 3, + } + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + + db.EXPECT().GetChatMessagesByChatID(gomock.Any(), database.GetChatMessagesByChatIDParams{ + ChatID: chat.ID, + }).Return([]database.ChatMessage{ + assistantReportMessage(t, chat.ID, 1, "intermediate progress"), + assistantReportMessage(t, chat.ID, 2, "final report"), + }, nil) + db.EXPECT().UpdateChatSummary(gomock.Any(), database.UpdateChatSummaryParams{ + ID: chat.ID, + ExpectedHistoryVersion: chat.HistoryVersion, + Summary: sql.NullString{String: "final report", Valid: true}, + }).Return(int64(1), nil) + + server.storeSubagentReportSummary(context.Background(), chat, logger) + }) + + t.Run("SkipsWhenNoVisibleReport", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + server := &Server{db: db} + chat := database.Chat{ + ID: uuid.New(), + OwnerID: uuid.New(), + ParentChatID: uuid.NullUUID{UUID: uuid.New(), Valid: true}, + HistoryVersion: 3, + } + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + + db.EXPECT().GetChatMessagesByChatID(gomock.Any(), database.GetChatMessagesByChatIDParams{ + ChatID: chat.ID, + }).Return([]database.ChatMessage{}, nil) + + server.storeSubagentReportSummary(context.Background(), chat, logger) + }) + + t.Run("TruncatesLongReport", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + server := &Server{db: db} + chat := database.Chat{ + ID: uuid.New(), + OwnerID: uuid.New(), + ParentChatID: uuid.NullUUID{UUID: uuid.New(), Valid: true}, + HistoryVersion: 3, + } + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + + longReport := strings.Repeat("a", subagentReportSummaryMaxRunes+100) + wantSummary := strings.Repeat("a", subagentReportSummaryMaxRunes-1) + "…" + + db.EXPECT().GetChatMessagesByChatID(gomock.Any(), database.GetChatMessagesByChatIDParams{ + ChatID: chat.ID, + }).Return([]database.ChatMessage{ + assistantReportMessage(t, chat.ID, 1, longReport), + }, nil) + db.EXPECT().UpdateChatSummary(gomock.Any(), database.UpdateChatSummaryParams{ + ID: chat.ID, + ExpectedHistoryVersion: chat.HistoryVersion, + Summary: sql.NullString{String: wantSummary, Valid: true}, + }).Return(int64(1), nil) + + server.storeSubagentReportSummary(context.Background(), chat, logger) + }) + + t.Run("StoresSnippetOfMarkdownReport", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + server := &Server{db: db} + chat := database.Chat{ + ID: uuid.New(), + OwnerID: uuid.New(), + ParentChatID: uuid.NullUUID{UUID: uuid.New(), Valid: true}, + HistoryVersion: 3, + } + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + + report := "## Result\n\nFixed **the race** in `cache.go`. " + + "Added a regression test.\n\nLonger explanation follows here." + + db.EXPECT().GetChatMessagesByChatID(gomock.Any(), database.GetChatMessagesByChatIDParams{ + ChatID: chat.ID, + }).Return([]database.ChatMessage{ + assistantReportMessage(t, chat.ID, 1, report), + }, nil) + db.EXPECT().UpdateChatSummary(gomock.Any(), database.UpdateChatSummaryParams{ + ID: chat.ID, + ExpectedHistoryVersion: chat.HistoryVersion, + Summary: sql.NullString{ + String: "Fixed the race in cache.go. Added a regression test.", + Valid: true, + }, + }).Return(int64(1), nil) + + server.storeSubagentReportSummary(context.Background(), chat, logger) + }) +} + func TestMaybeGenerateChatSummaryAsync_CloseCancelsInflight(t *testing.T) { t.Parallel() diff --git a/coderd/x/chatd/generation_preparer.go b/coderd/x/chatd/generation_preparer.go index 06b47af4e4..fcbfbf744d 100644 --- a/coderd/x/chatd/generation_preparer.go +++ b/coderd/x/chatd/generation_preparer.go @@ -709,8 +709,8 @@ func (server *Server) afterInterruptionOutcome( chat := outcome.Chat logger := server.logger.With(slog.F("chat_id", chat.ID), slog.F("owner_id", chat.OwnerID)) - if outcome.Kind == runnerActionKindFinishInterruption { - server.maybeClearLastTurnSummaryAsync(context.WithoutCancel(ctx), chat, logger) + if outcome.Kind == runnerActionKindFinishInterruption && !chat.ParentChatID.Valid { + server.clearLastTurnSummaryAsync(context.WithoutCancel(ctx), chat, logger) } return nil } diff --git a/coderd/x/chatd/quickgen.go b/coderd/x/chatd/quickgen.go index 16e1dea454..dfbcfac6e5 100644 --- a/coderd/x/chatd/quickgen.go +++ b/coderd/x/chatd/quickgen.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "net/http" + "regexp" "slices" "strings" "time" @@ -1171,6 +1172,145 @@ func countSentenceTerminators(text string) int { return count } +// markdownLinkRe matches inline links and images so snippet extraction +// can keep the link text and drop the URL. +var markdownLinkRe = regexp.MustCompile(`!?\[([^\]]*)\]\([^)]*\)`) + +func subagentReportSummarySnippet(report string) string { + paragraph := firstReportParagraph(report) + if paragraph == "" { + return "" + } + return boundSnippetSentences( + paragraph, + subagentReportSummaryMaxSentences, + subagentReportSummaryMaxRunes, + ) +} + +func firstReportParagraph(report string) string { + var paragraph []string + inFence := false + for line := range strings.Lines(report) { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~") { + if !inFence && len(paragraph) > 0 { + break + } + inFence = !inFence + continue + } + if inFence { + continue + } + if trimmed == "" || isMarkdownStructureLine(trimmed) { + if len(paragraph) > 0 { + break + } + continue + } + content := stripInlineMarkdown(stripLineMarkers(trimmed)) + if content == "" { + if len(paragraph) > 0 { + break + } + continue + } + paragraph = append(paragraph, content) + } + return strings.TrimSpace(strings.Join(paragraph, " ")) +} + +func isMarkdownStructureLine(trimmed string) bool { + if strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, "|") { + return true + } + // Horizontal rules: at least three of the same marker character + // and nothing else. + if len(trimmed) >= 3 && strings.Trim(trimmed, "-") == "" { + return true + } + if len(trimmed) >= 3 && strings.Trim(trimmed, "*") == "" { + return true + } + if len(trimmed) >= 3 && strings.Trim(trimmed, "_") == "" { + return true + } + return false +} + +func stripLineMarkers(trimmed string) string { + for { + next := trimmed + next = strings.TrimPrefix(next, ">") + if rest, ok := trimListMarker(next); ok { + next = rest + } + next = strings.TrimSpace(next) + if next == trimmed { + return trimmed + } + trimmed = next + } +} + +// trimListMarker strips one leading bullet ("- ", "* ", "+ "), ordered +// ("1. ", "1) "), or task-list ("[ ] ", "[x] ") marker. +func trimListMarker(line string) (string, bool) { + for _, marker := range []string{"- ", "* ", "+ ", "[ ] ", "[x] ", "[X] "} { + if rest, ok := strings.CutPrefix(line, marker); ok { + return rest, true + } + } + digits := 0 + for _, r := range line { + if r < '0' || r > '9' { + break + } + digits++ + } + if digits > 0 && len(line) > digits+1 && + (line[digits] == '.' || line[digits] == ')') && line[digits+1] == ' ' { + return line[digits+2:], true + } + return line, false +} + +func stripInlineMarkdown(text string) string { + text = markdownLinkRe.ReplaceAllString(text, "$1") + replacer := strings.NewReplacer("**", "", "__", "", "~~", "", "`", "") + return strings.TrimSpace(replacer.Replace(text)) +} + +func boundSnippetSentences(text string, maxSentences, maxRunes int) string { + runes := []rune(text) + sentences := 0 + lastEnd := 0 + for i, r := range runes { + if r != '.' && r != '!' && r != '?' { + continue + } + if i != len(runes)-1 && !unicode.IsSpace(runes[i+1]) { + continue + } + if i+1 > maxRunes { + break + } + lastEnd = i + 1 + sentences++ + if sentences >= maxSentences { + break + } + } + if lastEnd > 0 { + return strings.TrimSpace(string(runes[:lastEnd])) + } + if len(runes) <= maxRunes { + return text + } + return strings.TrimSpace(string(runes[:maxRunes-1])) + "…" +} + const turnStatusLabelPrompt = "You write compact chat status labels for a sidebar or push notification. " + "Given a chat title, current chat state, and the agent's latest message, populate the label field with a 2-5 word status label. " + "Describe the chat's current state, not the agent. " + diff --git a/coderd/x/chatd/summarygen_internal_test.go b/coderd/x/chatd/summarygen_internal_test.go index d12a108beb..824dbb3c6d 100644 --- a/coderd/x/chatd/summarygen_internal_test.go +++ b/coderd/x/chatd/summarygen_internal_test.go @@ -244,3 +244,87 @@ func TestCountSentenceTerminators(t *testing.T) { "Refactored pkg.cmd.server and auth.rbac.Policy in main.go and util.go. Added coverage in foo_test.go.", )) } + +func TestSubagentReportSummarySnippet(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + report string + want string + }{ + { + name: "ProseLeadKeepsFirstSentences", + report: "Done. Both fixes are pushed as separate commits. Validation passed. Extra detail here.\n\nMore paragraphs follow.", + want: "Done. Both fixes are pushed as separate commits. Validation passed.", + }, + { + name: "SkipsLeadingHeading", + report: "## Summary\n\nFixed the flaky test by pinning the clock.", + want: "Fixed the flaky test by pinning the clock.", + }, + { + name: "StripsInlineMarkdown", + report: "Fixed **the race** in `cache.go`; see [the PR](https://example.com) for details.", + want: "Fixed the race in cache.go; see the PR for details.", + }, + { + name: "JoinsWrappedLines", + report: "Fixed the race\nin the cache layer.\n\nDetails below.", + want: "Fixed the race in the cache layer.", + }, + { + name: "BulletLeadReport", + report: "- Fixed A.\n- Fixed B.\n- Fixed C.\n- Fixed D.", + want: "Fixed A. Fixed B. Fixed C.", + }, + { + name: "SkipsLeadingCodeFence", + report: "```\ngo test ./...\n```\n\nAll tests passed.", + want: "All tests passed.", + }, + { + name: "FenceEndsParagraph", + report: "Ran the suite:\n```\nok 12 packages\n```\nThen more prose.", + want: "Ran the suite:", + }, + { + name: "SkipsTableAndRule", + report: "| a | b |\n|---|---|\n\n---\n\nRolled out the migration.", + want: "Rolled out the migration.", + }, + { + name: "PreservesSnakeCaseIdentifiers", + report: "Renamed parent_chat_id to root_chat_id in the query.", + want: "Renamed parent_chat_id to root_chat_id in the query.", + }, + { + name: "TruncatesUnterminatedText", + report: strings.Repeat("a", subagentReportSummaryMaxRunes+100), + want: strings.Repeat("a", subagentReportSummaryMaxRunes-1) + "…", + }, + { + name: "DropsSentencesPastRuneCap", + report: "Short lead sentence. " + + strings.Repeat("b", subagentReportSummaryMaxRunes) + ".", + want: "Short lead sentence.", + }, + { + name: "EmptyReport", + report: " \n\t\n", + want: "", + }, + { + name: "OnlyCodeAndHeadings", + report: "## Log\n```\nstack trace\n```\n", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tt.want, subagentReportSummarySnippet(tt.report)) + }) + } +} diff --git a/coderd/x/chatd/turn_summary_internal_test.go b/coderd/x/chatd/turn_summary_internal_test.go index e37f79231e..d8494c4ab1 100644 --- a/coderd/x/chatd/turn_summary_internal_test.go +++ b/coderd/x/chatd/turn_summary_internal_test.go @@ -18,6 +18,7 @@ import ( "github.com/coder/coder/v2/coderd/x/chatd/chatstate" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" + "github.com/coder/quartz" ) func TestUpdateLastTurnSummaryRejectsStaleWrites(t *testing.T) { @@ -112,7 +113,10 @@ func TestUpdateLastTurnSummaryRejectsStaleWrites(t *testing.T) { require.Equal(t, sql.NullString{String: "fresh summary", Valid: true}, fetched.LastTurnSummary) } -func TestSuccessfulChildChatOutcomeSkipsSummaryAndWebPush(t *testing.T) { +// A successful child chat outcome persists the subagent's final report +// as the chat summary but still skips the turn status label and web +// push, which remain parent-only. +func TestSuccessfulChildChatOutcomeStoresReportSummaryWithoutPush(t *testing.T) { t.Parallel() db, ps := dbtestutil.NewDB(t) @@ -168,6 +172,14 @@ func TestSuccessfulChildChatOutcomeSkipsSummaryAndWebPush(t *testing.T) { }) require.NoError(t, err) + const report = "Completed the delegated task." + insertAssistantMessage(t, db, child.ID, modelCfg.ID, report) + // Message inserts bump history_version via trigger; the finalize + // hook receives the post-turn chat, so mirror that here or the + // fenced summary write would be skipped as stale. + child, err = db.GetChatByID(ctx, child.ID) + require.NoError(t, err) + dispatcher := &recordingWebpushDispatcher{} server := &Server{ ctx: t.Context(), @@ -175,6 +187,11 @@ func TestSuccessfulChildChatOutcomeSkipsSummaryAndWebPush(t *testing.T) { pubsub: ps, logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), webpushDispatcher: dispatcher, + // deriveFinalTurnRunResult resolves the chat model once the + // child has an assistant message, which requires the cache and + // the clock used to mint the synthetic gateway API key. + clock: quartz.NewReal(), + configCache: newChatConfigCache(context.Background(), db, quartz.NewReal()), } require.NoError(t, server.afterGenerationOutcome(ctx, generationOutcome{ Chat: child, @@ -185,6 +202,7 @@ func TestSuccessfulChildChatOutcomeSkipsSummaryAndWebPush(t *testing.T) { fetched, err := db.GetChatByID(ctx, child.ID) require.NoError(t, err) require.False(t, fetched.LastTurnSummary.Valid) + require.Equal(t, sql.NullString{String: report, Valid: true}, fetched.Summary) require.Equal(t, int32(0), dispatcher.dispatchCount.Load()) } diff --git a/codersdk/chats.go b/codersdk/chats.go index f72c852b88..6dd5192fc8 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -1923,19 +1923,19 @@ type ChatCostUsersOptions struct { // ChatCostSummary is the response from the chat cost summary endpoint. type ChatCostSummary struct { - StartDate time.Time `json:"start_date" format:"date-time"` - EndDate time.Time `json:"end_date" format:"date-time"` - TotalCostMicros int64 `json:"total_cost_micros"` - PricedMessageCount int64 `json:"priced_message_count"` - UnpricedMessageCount int64 `json:"unpriced_message_count"` - TotalInputTokens int64 `json:"total_input_tokens"` - TotalOutputTokens int64 `json:"total_output_tokens"` - TotalCacheReadTokens int64 `json:"total_cache_read_tokens"` - TotalCacheCreationTokens int64 `json:"total_cache_creation_tokens"` - TotalRuntimeMs int64 `json:"total_runtime_ms"` - ByModel []ChatCostModelBreakdown `json:"by_model"` - ByChat []ChatCostChatBreakdown `json:"by_chat"` - UsageLimit *ChatUsageLimitStatus `json:"usage_limit,omitempty"` + StartDate time.Time `json:"start_date" format:"date-time"` + EndDate time.Time `json:"end_date" format:"date-time"` + TotalCostMicros int64 `json:"total_cost_micros"` + PricedMessageCount int64 `json:"priced_message_count"` + UnpricedMessagesHavingUsageCount int64 `json:"unpriced_messages_having_usage_count"` + TotalInputTokens int64 `json:"total_input_tokens"` + TotalOutputTokens int64 `json:"total_output_tokens"` + TotalCacheReadTokens int64 `json:"total_cache_read_tokens"` + TotalCacheCreationTokens int64 `json:"total_cache_creation_tokens"` + TotalRuntimeMs int64 `json:"total_runtime_ms"` + ByModel []ChatCostModelBreakdown `json:"by_model"` + ByChat []ChatCostChatBreakdown `json:"by_chat"` + UsageLimit *ChatUsageLimitStatus `json:"usage_limit,omitempty"` } // ChatCostModelBreakdown contains per-model cost aggregation. @@ -1966,6 +1966,17 @@ type ChatCostChatBreakdown struct { TotalRuntimeMs int64 `json:"total_runtime_ms"` } +// ChatCost is the cumulative cost for a selected chat's subtree: the +// chat itself plus every descendant (subagent) chat it spawned. A root +// chat therefore reports its whole tree, while a subagent reports only +// its own spend plus any nested subagents. +type ChatCost struct { + ChatID uuid.UUID `json:"chat_id" format:"uuid"` + TotalCostMicros int64 `json:"total_cost_micros"` + PricedMessageCount int64 `json:"priced_message_count"` + UnpricedMessagesHavingUsageCount int64 `json:"unpriced_messages_having_usage_count"` +} + // ChatCostUserRollup contains per-user cost aggregation for admin views. type ChatCostUserRollup struct { UserID uuid.UUID `json:"user_id" format:"uuid"` @@ -2541,6 +2552,20 @@ func (c *ExperimentalClient) GetChatCostSummary(ctx context.Context, user string return summary, json.NewDecoder(res.Body).Decode(&summary) } +// GetChatCost returns the cumulative cost for a single chat. +func (c *ExperimentalClient) GetChatCost(ctx context.Context, chatID uuid.UUID) (ChatCost, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/experimental/chats/%s/cost", chatID), nil) + if err != nil { + return ChatCost{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return ChatCost{}, ReadBodyAsError(res) + } + var cost ChatCost + return cost, json.NewDecoder(res.Body).Decode(&cost) +} + // GetChatCostUsers returns a per-user cost rollup for the deployment // (admin only). Zero-valued StartDate or EndDate fields are omitted from // the request, letting the server apply its own defaults (typically the diff --git a/docs/reference/api/chats.md b/docs/reference/api/chats.md index 5e2e2c90f4..ca4de9f004 100644 --- a/docs/reference/api/chats.md +++ b/docs/reference/api/chats.md @@ -1283,6 +1283,48 @@ Experimental: this endpoint is subject to change. To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Get chat cost + +### Code samples + +```sh +# Example request using curl +curl -X GET http://coder-server:8080/api/experimental/chats/{chat}/cost \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /api/experimental/chats/{chat}/cost` + +Experimental: this endpoint is subject to change. + +### Parameters + +| Name | In | Type | Required | Description | +|--------|------|--------------|----------|-------------| +| `chat` | path | string(uuid) | true | Chat ID | + +### Example responses + +> 200 Response + +```json +{ + "chat_id": "efc9fe20-a1e5-4a8c-9c48-f1b30c1e4f86", + "priced_message_count": 0, + "total_cost_micros": 0, + "unpriced_messages_having_usage_count": 0 +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|--------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.ChatCost](schemas.md#codersdkchatcost) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Get chat diff contents ### Code samples diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index c118eb45b1..8739faca02 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -2517,6 +2517,26 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `description` | string | false | | Description is the tool's human-readable summary; may be empty. | | `name` | string | false | | Name is the tool name with the "__" prefix the agent adds stripped, so it reads as the server exposes it. | +## codersdk.ChatCost + +```json +{ + "chat_id": "efc9fe20-a1e5-4a8c-9c48-f1b30c1e4f86", + "priced_message_count": 0, + "total_cost_micros": 0, + "unpriced_messages_having_usage_count": 0 +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|----------------------------------------|---------|----------|--------------|-------------| +| `chat_id` | string | false | | | +| `priced_message_count` | integer | false | | | +| `total_cost_micros` | integer | false | | | +| `unpriced_messages_having_usage_count` | integer | false | | | + ## codersdk.ChatDiffContents ```json diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 7cb182a144..eb0f971d10 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -3441,6 +3441,12 @@ class ExperimentalApiMethods { ); return response.data; }; + getChatCost = async (chatId: string): Promise => { + const response = await this.axios.get( + `/api/experimental/chats/${chatId}/cost`, + ); + return response.data; + }; getChatMessages = async ( chatId: string, opts?: { before_id?: number; after_id?: number; limit?: number }, diff --git a/site/src/api/queries/chats.test.ts b/site/src/api/queries/chats.test.ts index 64e6d25c5b..89156318d5 100644 --- a/site/src/api/queries/chats.test.ts +++ b/site/src/api/queries/chats.test.ts @@ -15,6 +15,8 @@ import { chatACLKey, chatAdvisorConfig, chatAdvisorConfigKey, + chatCost, + chatCostKey, chatCostSummary, chatCostSummaryKey, chatDebugRunsKey, @@ -59,6 +61,7 @@ vi.mock("#/api/api", () => ({ createChat: vi.fn(), deleteChatQueuedMessage: vi.fn(), getChats: vi.fn(), + getChatCost: vi.fn(), getChatCostSummary: vi.fn(), getChatCostUsers: vi.fn(), createChatMessage: vi.fn(), @@ -870,6 +873,20 @@ describe("chat cost query factories", () => { ); }); + it("builds the per-chat cost query key and forwards the chat id", async () => { + const chatId = "chat-1"; + vi.mocked(API.experimental.getChatCost).mockResolvedValue( + {} as TypesGen.ChatCost, + ); + + const query = chatCost(chatId); + + expect(chatCostKey(chatId)).toEqual(["chats", chatId, "cost"]); + expect(query.queryKey).toEqual(["chats", chatId, "cost"]); + await query.queryFn(); + expect(API.experimental.getChatCost).toHaveBeenCalledWith(chatId); + }); + it("builds paginated cost users query with correct key and coerces empty username", async () => { const payload = { start_date: "2025-01-01", diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index 00b26af131..a23e7efd7d 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -1953,6 +1953,19 @@ export const chatCostSummary = (user = "me", params?: ChatCostDateParams) => ({ staleTime: 60_000, }); +export const chatCostKey = (chatId: string) => + [...chatsKey, chatId, "cost"] as const; + +// Chat cost changes only when a new assistant message is priced, so a short +// stale window refreshes the sidebar without refetching on every render. +const ASSISTANT_MESSAGE_PRICING_STALE_MS = 30_000; + +export const chatCost = (chatId: string) => ({ + queryKey: chatCostKey(chatId), + queryFn: () => API.experimental.getChatCost(chatId), + staleTime: ASSISTANT_MESSAGE_PRICING_STALE_MS, +}); + interface PaginatedChatCostUsersPayload { username: string; start_date: string; diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 815cef4543..8ea30011d0 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1945,6 +1945,20 @@ export interface ChatContextTool { readonly description?: string; } +// From codersdk/chats.go +/** + * ChatCost is the cumulative cost for a selected chat's subtree: the + * chat itself plus every descendant (subagent) chat it spawned. A root + * chat therefore reports its whole tree, while a subagent reports only + * its own spend plus any nested subagents. + */ +export interface ChatCost { + readonly chat_id: string; + readonly total_cost_micros: number; + readonly priced_message_count: number; + readonly unpriced_messages_having_usage_count: number; +} + // From codersdk/chats.go /** * ChatCostChatBreakdown contains per-root-chat cost aggregation. @@ -1988,7 +2002,7 @@ export interface ChatCostSummary { readonly end_date: string; readonly total_cost_micros: number; readonly priced_message_count: number; - readonly unpriced_message_count: number; + readonly unpriced_messages_having_usage_count: number; readonly total_input_tokens: number; readonly total_output_tokens: number; readonly total_cache_read_tokens: number; diff --git a/site/src/pages/AISettingsPage/SpendPage/SpendPageView.stories.tsx b/site/src/pages/AISettingsPage/SpendPage/SpendPageView.stories.tsx index 12e49c8916..47dd6ffa61 100644 --- a/site/src/pages/AISettingsPage/SpendPage/SpendPageView.stories.tsx +++ b/site/src/pages/AISettingsPage/SpendPage/SpendPageView.stories.tsx @@ -64,7 +64,7 @@ const mockCostSummary = { end_date: "2026-03-12T00:00:00Z", total_cost_micros: 2_500_000, priced_message_count: 40, - unpriced_message_count: 2, + unpriced_messages_having_usage_count: 2, total_input_tokens: 200_000, total_output_tokens: 300_000, total_cache_read_tokens: 10_000, diff --git a/site/src/pages/AISettingsPage/SpendPage/components/ChatCostSummaryView.stories.tsx b/site/src/pages/AISettingsPage/SpendPage/components/ChatCostSummaryView.stories.tsx index dbcf32f074..19d029cdbb 100644 --- a/site/src/pages/AISettingsPage/SpendPage/components/ChatCostSummaryView.stories.tsx +++ b/site/src/pages/AISettingsPage/SpendPage/components/ChatCostSummaryView.stories.tsx @@ -10,7 +10,7 @@ const buildSummary = ( end_date: "2026-03-12T00:00:00Z", total_cost_micros: 1_500_000, priced_message_count: 12, - unpriced_message_count: 0, + unpriced_messages_having_usage_count: 0, total_input_tokens: 123_456, total_output_tokens: 654_321, total_cache_read_tokens: 9_876, @@ -50,7 +50,7 @@ const buildSummary = ( const emptySummary = buildSummary({ total_cost_micros: 0, priced_message_count: 0, - unpriced_message_count: 0, + unpriced_messages_having_usage_count: 0, total_input_tokens: 0, total_output_tokens: 0, by_model: [], @@ -110,7 +110,7 @@ export const WithData: Story = { export const UnpricedWarning: Story = { args: { summary: buildSummary({ - unpriced_message_count: 2, + unpriced_messages_having_usage_count: 2, }), }, }; diff --git a/site/src/pages/AISettingsPage/SpendPage/components/ChatCostSummaryView.tsx b/site/src/pages/AISettingsPage/SpendPage/components/ChatCostSummaryView.tsx index 8d0ef16076..4f1e954f55 100644 --- a/site/src/pages/AISettingsPage/SpendPage/components/ChatCostSummaryView.tsx +++ b/site/src/pages/AISettingsPage/SpendPage/components/ChatCostSummaryView.tsx @@ -194,7 +194,8 @@ export const ChatCostSummaryView: FC = ({

{( - summary.priced_message_count + summary.unpriced_message_count + summary.priced_message_count + + summary.unpriced_messages_having_usage_count ).toLocaleString()}

@@ -253,13 +254,14 @@ export const ChatCostSummaryView: FC = ({ )} - {summary.unpriced_message_count > 0 && ( + {summary.unpriced_messages_having_usage_count > 0 && (
- {summary.unpriced_message_count} message - {summary.unpriced_message_count === 1 ? "" : "s"} could not be - priced because model pricing data was unavailable. + {summary.unpriced_messages_having_usage_count} message + {summary.unpriced_messages_having_usage_count === 1 ? "" : "s"} with + usage could not be priced because model pricing data was + unavailable.
)} diff --git a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx index cc58a59997..e99359d7c4 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx @@ -196,6 +196,16 @@ const StoryAgentChatPageView: FC = ({ editing, ...overrides }) => { const meta: Meta = { title: "pages/AgentsPage/AgentChatPageView", component: AgentChatPageView, + // Summary is the default tab and reads chat + cost; mock both so the sidebar renders. + beforeEach: () => { + spyOn(API.experimental, "getChat").mockResolvedValue(buildChat()); + spyOn(API.experimental, "getChatCost").mockResolvedValue({ + chat_id: AGENT_ID, + total_cost_micros: 0, + priced_message_count: 0, + unpriced_messages_having_usage_count: 0, + }); + }, decorators: [withAuthProvider, withDashboardProvider, withProxyProvider()], parameters: { layout: "fullscreen", @@ -479,6 +489,9 @@ index abc1234..def5678 100644 play: async ({ canvasElement }) => { const canvas = within(canvasElement); + // Summary is the default tab; switch to Git to view the PR diff. + await userEvent.click(canvas.getByRole("tab", { name: "Git" })); + // Wait for the initial diff fetch triggered by React Query. await waitFor(() => { expect(API.experimental.getChatDiffContents).toHaveBeenCalled(); @@ -1331,7 +1344,7 @@ export const StickyUserMessagePinsOnScroll: Story = { // which is the regression this story guards against. const sentinels = scrollContainer.querySelectorAll("[data-user-sentinel]"); expect(sentinels.length).toBeGreaterThan(0); - for (const sentinel of sentinels) { + for (const sentinel of Array.from(sentinels)) { expect(sentinel.closest("[data-testid='scroll-container']")).toBe( scrollContainer, ); @@ -1445,7 +1458,7 @@ export const StickyUserMessageClipUpdatesWhilePinned: Story = { // resize of that node reflects transcript growth. const sentinels = scrollContainer.querySelectorAll("[data-user-sentinel]"); expect(sentinels.length).toBeGreaterThan(0); - for (const sentinel of sentinels) { + for (const sentinel of Array.from(sentinels)) { expect(contentMarker?.contains(sentinel)).toBe(true); } @@ -1526,7 +1539,7 @@ export const TerminalFocusOnTabSwitch: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - // The sidebar should open on the Git tab by default. + // Sidebar defaults to Summary; this story drives the Terminal tab instead. const terminalTab = await canvas.findByRole("tab", { name: "Terminal" }); // 1. Click the Terminal tab. @@ -1624,8 +1637,8 @@ export const PersistsSidebarTabClick: Story = { const canvas = within(canvasElement); await waitFor(() => { - const gitTab = canvas.getByRole("tab", { name: "Git" }); - expect(gitTab).toHaveAttribute("aria-selected", "true"); + const summaryTab = canvas.getByRole("tab", { name: "Summary" }); + expect(summaryTab).toHaveAttribute("aria-selected", "true"); }); const terminalTab = canvas.getByRole("tab", { name: "Terminal" }); @@ -1640,15 +1653,11 @@ export const PersistsSidebarTabClick: Story = { }; /** - * When localStorage holds a tab ID whose tab is not currently available - * (e.g. `"terminal"` while the workspace is stopped), the sidebar - * should fall back to the first available tab (Git) and the stored - * value must be preserved so it can be honoured once the tab reappears. - * - * This locks down the contract described in the PR: `getEffectiveTabId` - * only reads `sidebarTabId` and never writes back. A future write-back - * in the fallback path would silently break restore-after-recovery, so - * this story exists to catch that regression. + * When localStorage holds an unavailable tab ID (e.g. `"terminal"` while the + * workspace is stopped), the sidebar falls back to the first available tab + * (Summary) while preserving the stored value for when the tab reappears. + * Guards the `getEffectiveTabId` contract: it only reads `sidebarTabId`, never + * writes back, so restore-after-recovery cannot silently break. */ export const PreservesUnavailableSidebarTab: Story = { beforeEach: () => { @@ -1662,8 +1671,8 @@ export const PreservesUnavailableSidebarTab: Story = { const canvas = within(canvasElement); await waitFor(() => { - const gitTab = canvas.getByRole("tab", { name: "Git" }); - expect(gitTab).toHaveAttribute("aria-selected", "true"); + const summaryTab = canvas.getByRole("tab", { name: "Summary" }); + expect(summaryTab).toHaveAttribute("aria-selected", "true"); }); expect(canvas.queryByRole("tab", { name: "Terminal" })).toBeNull(); @@ -1702,8 +1711,8 @@ export const DoesNotPersistForArchivedChat: Story = { const canvas = within(canvasElement); await waitFor(() => { - const gitTab = canvas.getByRole("tab", { name: "Git" }); - expect(gitTab).toHaveAttribute("aria-selected", "true"); + const summaryTab = canvas.getByRole("tab", { name: "Summary" }); + expect(summaryTab).toHaveAttribute("aria-selected", "true"); }); const terminalTab = canvas.getByRole("tab", { name: "Terminal" }); diff --git a/site/src/pages/AgentsPage/AgentChatPageView.tsx b/site/src/pages/AgentsPage/AgentChatPageView.tsx index cbe48cc83a..4bb0b50221 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.tsx @@ -41,6 +41,7 @@ import type { PendingAttachment } from "./components/ChatPageContent"; import { ChatPageInput, ChatPageTimeline } from "./components/ChatPageContent"; import { ChatScrollContainer } from "./components/ChatScrollContainer"; import { ChatSharingPopoverContent } from "./components/ChatSharingPopover"; +import { ChatSummaryPanel } from "./components/ChatSummaryPanel"; import { getEffectiveTabId } from "./components/ChatsSidebar/tabs/getEffectiveTabId"; import { SidebarTabView } from "./components/ChatsSidebar/tabs/SidebarTabView"; import { ChatTopBar } from "./components/ChatTopBar"; @@ -512,6 +513,7 @@ export const AgentChatPageView: FC = ({ // new tab can never be added to one without the other going out of // sync. Desktop is ordered before terminals so terminals are rightmost. const builtInSidebarTabConfigs = [ + { id: "summary", label: "Summary" }, { id: "git", label: "Git" }, ...(debugLoggingEnabled ? [{ id: "debug", label: "Debug" }] : []), ...(availableDesktopChatId ? [{ id: "desktop", label: "Desktop" }] : []), @@ -680,6 +682,13 @@ export const AgentChatPageView: FC = ({ const renderTabContent = (tabId: string): ReactNode => { switch (tabId) { + case "summary": + return ( + + ); case "git": return ( { ); }); }); + +describe(chatCostIdsToInvalidate.name, () => { + it.each<{ + name: string; + updatedChat: TypesGen.Chat; + eventKind: TypesGen.ChatWatchEventKind; + expected: readonly string[]; + }>([ + { + name: "invalidates when a status change ends active generation", + updatedChat: chatForFilterInvalidation({ status: "waiting" }), + eventKind: "status_change", + expected: ["chat-1"], + }, + { + name: "does not duplicate a self-referential root id", + updatedChat: chatForFilterInvalidation({ + status: "waiting", + root_chat_id: "chat-1", + }), + eventKind: "status_change", + expected: ["chat-1"], + }, + { + name: "invalidates the root chat's rolled-up cost when a subagent finishes", + updatedChat: chatForFilterInvalidation({ + id: "child-1", + parent_chat_id: "root-1", + root_chat_id: "root-1", + status: "waiting", + }), + eventKind: "status_change", + expected: ["child-1", "root-1"], + }, + { + name: "invalidates the parent and root when a nested subagent finishes", + updatedChat: chatForFilterInvalidation({ + id: "grandchild-1", + parent_chat_id: "child-1", + root_chat_id: "root-1", + status: "waiting", + }), + eventKind: "status_change", + expected: ["grandchild-1", "child-1", "root-1"], + }, + { + name: "waits while the chat is still active", + updatedChat: chatForFilterInvalidation({ status: "running" }), + eventKind: "status_change", + expected: [], + }, + { + name: "waits while a subagent is still active", + updatedChat: chatForFilterInvalidation({ + id: "child-1", + parent_chat_id: "root-1", + root_chat_id: "root-1", + status: "running", + }), + eventKind: "status_change", + expected: [], + }, + { + name: "waits while the chat is interrupting", + updatedChat: chatForFilterInvalidation({ status: "interrupting" }), + eventKind: "status_change", + expected: [], + }, + { + name: "ignores non-status events", + updatedChat: chatForFilterInvalidation({ status: "waiting" }), + eventKind: "summary_change", + expected: [], + }, + ])("$name", ({ updatedChat, eventKind, expected }) => { + expect(chatCostIdsToInvalidate(updatedChat, eventKind)).toEqual(expected); + }); +}); diff --git a/site/src/pages/AgentsPage/AgentsPageLayout.tsx b/site/src/pages/AgentsPage/AgentsPageLayout.tsx index cc30a490d3..482bc789f8 100644 --- a/site/src/pages/AgentsPage/AgentsPageLayout.tsx +++ b/site/src/pages/AgentsPage/AgentsPageLayout.tsx @@ -20,6 +20,7 @@ import { applyChatArchiveStateToCaches, archiveChat, cancelChatListRefetches, + chatCostKey, chatDiffContentsKey, chatKey, chatModelConfigs, @@ -58,6 +59,7 @@ import { cn } from "#/utils/cn"; import { pageTitle } from "#/utils/page"; import { createReconnectingWebSocket } from "#/utils/reconnectingWebSocket"; import { emptyInputStorageKey } from "./components/AgentCreateForm"; +import { isActiveChatStatus } from "./components/ChatConversation/chatStore"; import { ChatsSidebar, isSettingsView, @@ -124,6 +126,34 @@ export const shouldInvalidateFilteredChatList = ( ): boolean => !chat.parent_chat_id && FILTER_MEMBERSHIP_EVENT_KINDS.has(eventKind); +// Chat IDs whose cost queries must refetch after a watch event, or an +// empty array when the event cannot change any cost. Cost accrues while +// a chat generates, so refetch when a status change lands in a +// non-active status. The cost endpoint sums the requested chat's +// subtree (GetChatModelUsageCostByChatID walks parent_chat_id), so a +// subagent going idle must also refresh its ancestors' rolled-up +// totals. The watch payload only carries the immediate parent and the +// root, which covers every ancestor for nesting up to two levels deep; +// deeper intermediate ancestors are refreshed by the query staleTime. +export const chatCostIdsToInvalidate = ( + chat: TypesGen.Chat, + eventKind: TypesGen.ChatWatchEventKind, +): readonly string[] => { + if (eventKind !== "status_change" || isActiveChatStatus(chat.status)) { + return []; + } + // root_chat_id is self-referential on root chats and parent_chat_id + // equals root_chat_id at depth one; the set dedupes both cases. + const ids = new Set([chat.id]); + if (chat.parent_chat_id) { + ids.add(chat.parent_chat_id); + } + if (chat.root_chat_id) { + ids.add(chat.root_chat_id); + } + return [...ids]; +}; + const AgentsPageLayout: FC = () => { useAgentsPWA(); const queryClient = useQueryClient(); @@ -653,6 +683,15 @@ const AgentsPageLayout: FC = () => { if (shouldInvalidateFilteredChatList(updatedChat, chatEvent.kind)) { void invalidateChatListQueries(queryClient); } + for (const costChatId of chatCostIdsToInvalidate( + updatedChat, + chatEvent.kind, + )) { + void queryClient.invalidateQueries({ + queryKey: chatCostKey(costChatId), + exact: true, + }); + } if (chatEvent.kind === "context_dirty") { // The watch payload carries only the lightweight // context flags (the merge above applies them); diff --git a/site/src/pages/AgentsPage/components/ChatSummary.stories.tsx b/site/src/pages/AgentsPage/components/ChatSummary.stories.tsx new file mode 100644 index 0000000000..00f93ff608 --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatSummary.stories.tsx @@ -0,0 +1,105 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, within } from "storybook/test"; +import { ChatSummary } from "./ChatSummary"; + +const meta: Meta = { + title: "pages/AgentsPage/ChatSummary", + component: ChatSummary, + args: { + summary: + "Investigated the flaky CI job, traced it to a race in the cache layer, and added a regression test.", + createdAt: "2024-05-01T12:00:00Z", + updatedAt: "2024-05-02T15:30:00Z", + costMicros: 1_250_000, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const WithSummary: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("Created:")).toBeInTheDocument(); + await expect(canvas.getByText("Updated:")).toBeInTheDocument(); + await expect(canvas.getByText("Cost:")).toBeInTheDocument(); + await expect(canvas.getByText("May 1, 2024")).toBeInTheDocument(); + await expect(canvas.getByText("May 2, 2024")).toBeInTheDocument(); + await expect(canvas.queryByText(/12:00|15:30/)).not.toBeInTheDocument(); + // formatCostMicros is locale-pinned to en-US, so this is deterministic. + await expect(canvas.getByText("$1.25")).toBeInTheDocument(); + }, +}; + +export const NoSummary: Story = { + args: { summary: null }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("No summary yet.")).toBeInTheDocument(); + }, +}; + +// A subagent's summary is its final report, persisted when it +// completes, so an empty summary means the agent is still working. +export const SubagentSummaryPending: Story = { + args: { summary: null, isSubagent: true }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByText("Summary pending agent completion."), + ).toBeInTheDocument(); + }, +}; + +export const CostLoading: Story = { + args: { isCostLoading: true, costMicros: undefined }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByLabelText("Loading cost")).toBeInTheDocument(); + }, +}; + +export const CostAbsent: Story = { + args: { costMicros: null }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("Cost:")).toBeInTheDocument(); + await expect(canvas.getByText("-")).toBeInTheDocument(); + }, +}; + +export const SubCentCost: Story = { + args: { costMicros: 5_000 }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("$0.0050")).toBeInTheDocument(); + }, +}; + +export const CostError: Story = { + args: { costMicros: undefined, costError: true }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("Cost:")).toBeInTheDocument(); + await expect(canvas.getByText("Unavailable")).toBeInTheDocument(); + }, +}; + +export const PartialCost: Story = { + args: { costMicros: 0, unpricedMessagesHavingUsageCount: 3 }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByText( + "Excludes 3 messages with usage but without model pricing.", + ), + ).toBeInTheDocument(); + }, +}; diff --git a/site/src/pages/AgentsPage/components/ChatSummary.tsx b/site/src/pages/AgentsPage/components/ChatSummary.tsx new file mode 100644 index 0000000000..a7169576c1 --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatSummary.tsx @@ -0,0 +1,95 @@ +import type { FC, ReactNode } from "react"; +import { Skeleton } from "#/components/Skeleton/Skeleton"; +import { formatCostMicros } from "#/utils/currency"; +import { DATE_FORMAT, formatDateTime } from "#/utils/time"; + +const EMPTY_VALUE = "-"; + +interface ChatSummaryProps { + summary: string | null; + createdAt: string; + updatedAt: string; + /** Cumulative chat cost in microdollars (1 USD = 1,000,000). */ + costMicros?: number | null; + isCostLoading?: boolean; + costError?: boolean; + /** Assistant messages with usage but no model pricing; when > 0 the cost is partial and a note is shown. */ + unpricedMessagesHavingUsageCount?: number; + /** Subagent summaries are the agent's final report, persisted when it completes, so the empty state reads as pending rather than absent. */ + isSubagent?: boolean; +} + +export const ChatSummary: FC = ({ + summary, + createdAt, + updatedAt, + costMicros, + isCostLoading, + costError, + unpricedMessagesHavingUsageCount, + isSubagent, +}) => { + const trimmedSummary = summary?.trim(); + const hasUnpricedMessages = + !isCostLoading && + !costError && + costMicros != null && + unpricedMessagesHavingUsageCount != null && + unpricedMessagesHavingUsageCount > 0; + + return ( +
+ {trimmedSummary ? ( +

+ {trimmedSummary} +

+ ) : ( +

+ {isSubagent ? "Summary pending agent completion." : "No summary yet."} +

+ )} + +
+ + {formatDateTime(createdAt, DATE_FORMAT.MEDIUM_DATE)} + + + {formatDateTime(updatedAt, DATE_FORMAT.MEDIUM_DATE)} + + + {isCostLoading ? ( + + ) : costError ? ( + Unavailable + ) : costMicros != null ? ( + formatCostMicros(costMicros) + ) : ( + EMPTY_VALUE + )} + +
+ + {hasUnpricedMessages && ( +

+ Excludes {unpricedMessagesHavingUsageCount} message + {unpricedMessagesHavingUsageCount === 1 ? "" : "s"} with usage but + without model pricing. +

+ )} +
+ ); +}; + +interface ChatSummaryRowProps { + label: string; + children: ReactNode; +} + +const ChatSummaryRow: FC = ({ label, children }) => ( +
+
{label}
+
+ {children} +
+
+); diff --git a/site/src/pages/AgentsPage/components/ChatSummaryPanel.stories.tsx b/site/src/pages/AgentsPage/components/ChatSummaryPanel.stories.tsx new file mode 100644 index 0000000000..a3ccfc11d9 --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatSummaryPanel.stories.tsx @@ -0,0 +1,119 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { FC } from "react"; +import { expect, spyOn, waitFor, within } from "storybook/test"; +import { API } from "#/api/api"; +import type * as TypesGen from "#/api/typesGenerated"; +import { MockChat } from "#/testHelpers/chatEntities"; +import { withDashboardProvider } from "#/testHelpers/storybook"; +import { ChatSummaryPanel } from "./ChatSummaryPanel"; + +const mockCost: TypesGen.ChatCost = { + chat_id: MockChat.id, + total_cost_micros: 1_250_000, + priced_message_count: 8, + unpriced_messages_having_usage_count: 0, +}; + +type MockRequestOptions = { + cost?: TypesGen.ChatCost; + summary?: string | null; + chatError?: boolean; + parentChatId?: string; +}; + +const mockRequests = ({ + cost = mockCost, + summary = null, + chatError, + parentChatId, +}: MockRequestOptions = {}) => { + if (chatError) { + spyOn(API.experimental, "getChat").mockRejectedValue( + new Error("Failed to load chat"), + ); + } else { + spyOn(API.experimental, "getChat").mockResolvedValue({ + ...MockChat, + summary, + ...(parentChatId ? { parent_chat_id: parentChatId } : {}), + }); + } + + spyOn(API.experimental, "getChatCost").mockResolvedValue(cost); +}; + +// The Summary tab fills the right panel, so give stories a bounded height. +const PanelFrame = (Story: FC) => ( +
+ +
+); + +const meta: Meta = { + title: "pages/AgentsPage/ChatSummaryPanel", + component: ChatSummaryPanel, + decorators: [PanelFrame, withDashboardProvider], + args: { + chatId: MockChat.id, + isVisible: true, + }, +}; + +export default meta; +type Story = StoryObj; + +export const WithSummary: Story = { + beforeEach: () => + mockRequests({ + summary: + "Investigated the flaky CI job, traced it to a cache-layer race, and added a regression test.", + }), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await waitFor(() => { + expect( + canvas.getByText(/traced it to a cache-layer race/), + ).toBeInTheDocument(); + expect(canvas.getByText("$1.25")).toBeInTheDocument(); + }); + }, +}; + +// A running subagent has no summary yet; its report is persisted as the +// summary when it completes, so the empty state reads as pending. +export const SubagentSummaryPending: Story = { + beforeEach: () => mockRequests({ parentChatId: "parent-chat-id" }), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await waitFor(() => { + expect( + canvas.getByText("Summary pending agent completion."), + ).toBeInTheDocument(); + }); + }, +}; + +export const ChatError: Story = { + beforeEach: () => mockRequests({ chatError: true }), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await waitFor(() => { + expect(canvas.getByText("Failed to load chat")).toBeInTheDocument(); + }); + }, +}; + +export const NotVisible: Story = { + args: { isVisible: false }, + beforeEach: () => mockRequests({ summary: "Should never be fetched." }), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // Gating disables both queries, so nothing renders and no API call fires. + expect(API.experimental.getChat).not.toHaveBeenCalled(); + expect(API.experimental.getChatCost).not.toHaveBeenCalled(); + expect( + canvas.queryByText("Should never be fetched."), + ).not.toBeInTheDocument(); + expect(canvas.queryByText("No summary yet.")).not.toBeInTheDocument(); + }, +}; diff --git a/site/src/pages/AgentsPage/components/ChatSummaryPanel.tsx b/site/src/pages/AgentsPage/components/ChatSummaryPanel.tsx new file mode 100644 index 0000000000..9ddddefb90 --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatSummaryPanel.tsx @@ -0,0 +1,47 @@ +import type { FC, ReactNode } from "react"; +import { useQuery } from "react-query"; +import { chat, chatCost } from "#/api/queries/chats"; +import { ErrorAlert } from "#/components/Alert/ErrorAlert"; +import { ChatSummary } from "./ChatSummary"; + +type ChatSummaryPanelProps = { + chatId: string; + /** Gate reads on tab visibility so the chat and cost queries don't run while the tab is hidden. */ + isVisible: boolean; +}; + +export const ChatSummaryPanel: FC = ({ + chatId, + isVisible, +}) => { + const chatQuery = useQuery({ ...chat(chatId), enabled: isVisible }); + const costQuery = useQuery({ ...chatCost(chatId), enabled: isVisible }); + + const chatData = chatQuery.data; + + let content: ReactNode = null; + if (chatQuery.isError) { + content = ; + } else if (chatData) { + content = ( + + ); + } + + return ( +
+ {content} +
+ ); +};