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
+54 -11
View File
@@ -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() != ""
}
+137
View File
@@ -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()
+2 -2
View File
@@ -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
}
+140
View File
@@ -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. " +
@@ -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))
})
}
}
+19 -1
View File
@@ -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())
}