diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index ee17c598257..7951b36a14f 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -4660,7 +4660,7 @@ const ( // 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 + // Bound the extracted report snippet near the headline of the // generated summaries that root chats get, so subagent and parent // summary panels read the same. subagentReportSummaryMaxRunes = 300 diff --git a/coderd/x/chatd/quickgen.go b/coderd/x/chatd/quickgen.go index 3ad0236c0f7..4aba1e76ad8 100644 --- a/coderd/x/chatd/quickgen.go +++ b/coderd/x/chatd/quickgen.go @@ -914,11 +914,14 @@ func generateManualTitle( } const chatSummaryGenerationPrompt = "You summarize an AI coding chat for a quick-reference popover. " + - "Populate the summary field with 1 to 3 plain sentences describing what the conversation is about and what was accomplished or attempted. " + + "Populate the headline field with one sentence naming what the conversation is about and its outcome. " + + "Populate the bullets field with 2 to 4 short bullets covering what was done or attempted, each a single line. " + + "Leave the bullets field empty when the headline already covers the whole chat, rather than padding it with filler. " + "Write about the conversation in the third person. " + - "Preserve specific identifiers such as PR numbers, repo names, file paths, function names, and error messages. " + + "Preserve specific identifiers such as PR numbers, repo names, file paths, function names, and error messages, " + + "wrapping them in backticks. " + "Do not address the user, give instructions, or continue the task. " + - "No markdown, lists, headings, code fences, or surrounding quotes." + "No headings, code fences, tables, or nested lists." const ( // Bound the transcript so the summary call stays cheap and within context; @@ -927,14 +930,19 @@ const ( // Cap a single turn so one long message cannot dominate the budget. summaryTranscriptPerMessageMaxRunes = 4000 summaryMaxOutputTokens = 512 - // Reject pathologically long or verbose summaries, with slack over the - // 1-3 sentence target. - summaryMaxRunes = 1000 - summaryMaxSentences = 6 + // Reject pathologically long or verbose summaries. + summaryMaxRunes = 600 + summaryHeadlineMaxRunes = 200 + summaryHeadlineMaxSentences = 2 + summaryBulletMaxRunes = 160 + // Upper bound only; requiring bullets would pad trivial chats with + // filler or reject them, leaving the panel empty. + summaryMaxBullets = 4 ) type generatedChatSummary struct { - Summary string `json:"summary" description:"1-3 sentence summary of the whole chat"` + Headline string `json:"headline" description:"One sentence naming what the chat is about and its outcome"` + Bullets []string `json:"bullets" description:"2-4 short bullets, each one line, covering what was done or attempted; empty when the headline already covers the whole chat"` } // renderChatSummaryTranscript renders chat history as plain text for summary @@ -1034,12 +1042,16 @@ func boundTranscriptHeadTail(lines []string, maxRunes int) string { } func summaryObjectCall(resolved resolvedModelCall) fantasy.ObjectCall { - return resolved.newObjectCall("chat_summary", "Summarize the whole chat in 1-3 sentences.", summaryMaxOutputTokens) + return resolved.newObjectCall( + "chat_summary", + "Summarize the whole chat as a one-sentence headline plus up to 4 short bullets.", + summaryMaxOutputTokens, + ) } -// generateChatSummary generates a 1-3 sentence whole-chat summary from a -// transcript. A blank or invalid result returns an error so callers preserve -// any existing summary rather than clearing it. +// generateChatSummary generates a headline-plus-bullets summary from a +// transcript, serialized to markdown. A blank or invalid result returns an +// error so callers preserve any existing summary rather than clearing it. func generateChatSummary( ctx context.Context, model fantasy.LanguageModel, @@ -1066,22 +1078,70 @@ func generateChatSummary( return "", usage, xerrors.Errorf("generate chat summary: %w", err) } - summary := normalizeShortTextOutput(result.Object.Summary) + summary := generatedChatSummary{ + Headline: normalizeSummaryField(result.Object.Headline), + Bullets: normalizeSummaryBullets(result.Object.Bullets), + } if err := validateGeneratedChatSummary(summary); err != nil { return "", result.Usage, err } - return summary, result.Usage, nil + return formatChatSummaryMarkdown(summary.Headline, summary.Bullets), result.Usage, nil +} + +// normalizeSummaryField collapses a field onto one line. Unlike +// normalizeShortTextOutput it preserves backticks, keeping inline code spans +// balanced. +func normalizeSummaryField(text string) string { + text = strings.Trim(strings.TrimSpace(text), "\"'") + return strings.Join(strings.Fields(text), " ") } -func validateGeneratedChatSummary(summary string) error { - if summary == "" { - return xerrors.New("generated chat summary was empty") +func normalizeSummaryBullets(bullets []string) []string { + normalized := make([]string, 0, len(bullets)) + for _, bullet := range bullets { + if bullet = normalizeSummaryField(bullet); bullet != "" { + normalized = append(normalized, bullet) + } } - if len([]rune(summary)) > summaryMaxRunes { - return xerrors.Errorf("generated chat summary exceeded %d runes", summaryMaxRunes) + return normalized +} + +// formatChatSummaryMarkdown renders a headline paragraph plus an optional +// bullet list. Bullets must already be normalized: no blanks, no newlines. +func formatChatSummaryMarkdown(headline string, bullets []string) string { + headline = strings.TrimSpace(headline) + if len(bullets) == 0 { + return headline } - if countSentenceTerminators(summary) > summaryMaxSentences { - return xerrors.Errorf("generated chat summary exceeded %d sentences", summaryMaxSentences) + return strings.TrimSpace(headline + "\n\n- " + strings.Join(bullets, "\n- ")) +} + +// validateGeneratedChatSummary checks the structured fields rather than the +// rendered markdown: bullets omit trailing punctuation, so a sentence count +// over the serialized string would pass almost anything. +func validateGeneratedChatSummary(summary generatedChatSummary) error { + if summary.Headline == "" { + return xerrors.New("generated chat summary headline was empty") + } + if len([]rune(summary.Headline)) > summaryHeadlineMaxRunes { + return xerrors.Errorf("generated chat summary headline exceeded %d runes", summaryHeadlineMaxRunes) + } + if countSentenceTerminators(summary.Headline) > summaryHeadlineMaxSentences { + return xerrors.Errorf("generated chat summary headline exceeded %d sentences", summaryHeadlineMaxSentences) + } + if len(summary.Bullets) > summaryMaxBullets { + return xerrors.Errorf( + "generated chat summary had %d bullets, want at most %d", + len(summary.Bullets), summaryMaxBullets, + ) + } + for _, bullet := range summary.Bullets { + if len([]rune(bullet)) > summaryBulletMaxRunes { + return xerrors.Errorf("generated chat summary bullet exceeded %d runes", summaryBulletMaxRunes) + } + } + if rendered := formatChatSummaryMarkdown(summary.Headline, summary.Bullets); len([]rune(rendered)) > summaryMaxRunes { + return xerrors.Errorf("generated chat summary exceeded %d runes", summaryMaxRunes) } return nil } diff --git a/coderd/x/chatd/summarygen_internal_test.go b/coderd/x/chatd/summarygen_internal_test.go index 824dbb3c6db..3ec0545f844 100644 --- a/coderd/x/chatd/summarygen_internal_test.go +++ b/coderd/x/chatd/summarygen_internal_test.go @@ -225,10 +225,89 @@ func TestShouldGenerateChatSummary(t *testing.T) { func TestValidateGeneratedChatSummary(t *testing.T) { t.Parallel() - require.Error(t, validateGeneratedChatSummary("")) - require.Error(t, validateGeneratedChatSummary(strings.Repeat("a", summaryMaxRunes+1))) - require.Error(t, validateGeneratedChatSummary("One. Two. Three. Four. Five. Six. Seven.")) - require.NoError(t, validateGeneratedChatSummary("Implemented the summary feature. Added tests.")) + validBullets := []string{"Traced the race in `cache.go`", "Added a regression test"} + + tests := []struct { + name string + summary generatedChatSummary + wantErr bool + }{ + { + name: "Valid", + summary: generatedChatSummary{Headline: "Fixed the flaky CI job.", Bullets: validBullets}, + }, + { + name: "EmptyHeadline", + summary: generatedChatSummary{Bullets: validBullets}, + wantErr: true, + }, + { + name: "HeadlineTooLong", + summary: generatedChatSummary{ + Headline: strings.Repeat("a", summaryHeadlineMaxRunes+1), + Bullets: validBullets, + }, + wantErr: true, + }, + { + name: "HeadlineTooManySentences", + summary: generatedChatSummary{ + Headline: "One. Two. Three.", + Bullets: validBullets, + }, + wantErr: true, + }, + { + name: "SingleBullet", + summary: generatedChatSummary{Headline: "Fixed it.", Bullets: []string{"Only one"}}, + }, + { + // A trivial chat is fully described by its headline. + name: "NoBullets", + summary: generatedChatSummary{Headline: "Fixed a typo in `README.md`."}, + }, + { + name: "TooManyBullets", + summary: generatedChatSummary{ + Headline: "Fixed it.", + Bullets: []string{"One", "Two", "Three", "Four", "Five"}, + }, + wantErr: true, + }, + { + name: "BulletTooLong", + summary: generatedChatSummary{ + Headline: "Fixed it.", + Bullets: []string{"Fine", strings.Repeat("b", summaryBulletMaxRunes+1)}, + }, + wantErr: true, + }, + { + name: "SerializedTooLong", + summary: generatedChatSummary{ + Headline: strings.Repeat("a", summaryHeadlineMaxRunes), + Bullets: []string{ + strings.Repeat("b", summaryBulletMaxRunes), + strings.Repeat("c", summaryBulletMaxRunes), + strings.Repeat("d", summaryBulletMaxRunes), + }, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := validateGeneratedChatSummary(tt.summary) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + }) + } } func TestCountSentenceTerminators(t *testing.T) { @@ -239,10 +318,83 @@ func TestCountSentenceTerminators(t *testing.T) { require.Equal(t, 3, countSentenceTerminators("One. Two! Three?")) require.Equal(t, 0, countSentenceTerminators("auth.rbac.Policy")) - // Dotted identifiers must not push a valid summary over the sentence cap. - require.NoError(t, validateGeneratedChatSummary( - "Refactored pkg.cmd.server and auth.rbac.Policy in main.go and util.go. Added coverage in foo_test.go.", - )) + // Dotted identifiers must not push a valid headline over the sentence cap. + require.NoError(t, validateGeneratedChatSummary(generatedChatSummary{ + Headline: "Refactored pkg.cmd.server and auth.rbac.Policy in main.go and util.go.", + Bullets: []string{"Updated call sites", "Added coverage in foo_test.go"}, + })) +} + +func TestNormalizeSummaryField(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + text string + want string + }{ + {name: "Empty", text: " ", want: ""}, + {name: "CollapsesNewlines", text: "Fixed the race\nin cache.go", want: "Fixed the race in cache.go"}, + {name: "CollapsesRuns", text: "Fixed the\t\trace", want: "Fixed the race"}, + {name: "StripsSurroundingQuotes", text: `"Fixed the race"`, want: "Fixed the race"}, + { + // normalizeShortTextOutput would strip this and unbalance the span. + name: "PreservesTrailingBacktick", + text: "Fixed `cache.go`", + want: "Fixed `cache.go`", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + require.Equal(t, tt.want, normalizeSummaryField(tt.text)) + }) + } +} + +func TestNormalizeSummaryBullets(t *testing.T) { + t.Parallel() + + require.Equal(t, + []string{"First bullet", "Second bullet"}, + normalizeSummaryBullets([]string{" First\nbullet ", " ", "Second bullet", ""}), + ) + require.Empty(t, normalizeSummaryBullets(nil)) +} + +func TestFormatChatSummaryMarkdown(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + headline string + bullets []string + want string + }{ + { + name: "HeadlineOnly", + headline: "Fixed the flaky CI job.", + want: "Fixed the flaky CI job.", + }, + { + // Without the blank line, CommonMark folds the first bullet + // into the headline paragraph. + name: "HeadlineAndBullets", + headline: "Fixed the flaky CI job.", + bullets: []string{"Traced the race", "Added a test"}, + want: "Fixed the flaky CI job.\n\n- Traced the race\n- Added a test", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + require.Equal(t, tt.want, formatChatSummaryMarkdown(tt.headline, tt.bullets)) + }) + } } func TestSubagentReportSummarySnippet(t *testing.T) { diff --git a/site/src/@types/storybook.d.ts b/site/src/@types/storybook.d.ts index 9bf5d06ed17..f81262eefac 100644 --- a/site/src/@types/storybook.d.ts +++ b/site/src/@types/storybook.d.ts @@ -13,8 +13,17 @@ import type { Permissions } from "#/modules/permissions"; declare module "@storybook/react-vite" { type WebSocketEvent = - | { event: "message"; data: string } - | { event: "open" | "error" | "close" }; + | { + event: "message"; + data: string; + delayMs?: number; + connectionIndex?: number; + } + | { + event: "open" | "error" | "close"; + delayMs?: number; + connectionIndex?: number; + }; interface Parameters { features?: (FeatureName | ({ name: FeatureName } & Partial))[]; experiments?: Experiments; diff --git a/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx b/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx index aa09e5ff030..15adf85bd38 100644 --- a/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx @@ -5,6 +5,7 @@ import { expect, fireEvent, fn, + mocked, screen, spyOn, userEvent, @@ -55,6 +56,7 @@ import { } from "./components/ChatsSidebar/sidebarWidth"; import { ChatTopBar } from "./components/ChatTopBar"; import { RIGHT_PANEL_OPEN_KEY } from "./components/RightPanel/RightPanel"; +import { clearPersistedSidebarTabId } from "./utils/sidebarTabStorage"; const defaultModelID = "model-config-1"; @@ -1008,6 +1010,12 @@ const agentsWithAgentChatPageRouting = { }; const WATCHED_CHAT_ID = "chat-watched"; +const watchedChatCost: TypesGen.ChatCost = { + chat_id: WATCHED_CHAT_ID, + total_cost_micros: 1_250_000, + request_count: 8, + unpriced_request_count: 0, +}; // MockChat is owned by MockUserOwner, so the page renders the owner view // (composer enabled unless archived) instead of the other-user banner. @@ -1048,14 +1056,31 @@ const watchedChatQueries = (chat: Chat) => [ }, ]; -const chatWatchEvent = (kind: TypesGen.ChatWatchEventKind, chat: Chat) => ({ +const chatWatchEvent = ( + kind: TypesGen.ChatWatchEventKind, + chat: Chat, + delayMs = 0, + connectionIndex?: number, +) => ({ event: "message" as const, - data: JSON.stringify({ kind, chat } satisfies TypesGen.ChatWatchEvent), + data: JSON.stringify({ + kind, + chat, + } satisfies TypesGen.ChatWatchEvent), + delayMs, + connectionIndex, }); const watchedChatPageParameters = ( chat: Chat, - watchEvents: readonly ReturnType[], + watchEvents: readonly ( + | ReturnType + | { + event: "open" | "close" | "error"; + delayMs?: number; + connectionIndex?: number; + } + )[], ) => ({ queries: watchedChatQueries(chat), webSocket: { @@ -1073,10 +1098,91 @@ const watchedChatPageParameters = ( const mockAgentChatPageAPIs = () => { localStorage.removeItem(RIGHT_PANEL_OPEN_KEY); spyOn(API, "getApiKey").mockRejectedValue(new Error("missing API key")); + spyOn(API.experimental, "getChatCost").mockResolvedValue(watchedChatCost); spyOn(API.experimental, "updateChat").mockResolvedValue(); return () => localStorage.removeItem(RIGHT_PANEL_OPEN_KEY); }; +export const SummaryWatchEventsUpdateOpenPanel: Story = { + decorators: [withProxyProvider()], + beforeEach: () => { + mockChats([watchedChat()]); + const cleanup = mockAgentChatPageAPIs(); + clearPersistedSidebarTabId(WATCHED_CHAT_ID); + localStorage.setItem(RIGHT_PANEL_OPEN_KEY, "true"); + return () => { + clearPersistedSidebarTabId(WATCHED_CHAT_ID); + cleanup(); + }; + }, + parameters: watchedChatPageParameters(watchedChat(), [ + chatWatchEvent( + "chat_summary_change", + watchedChat({ summary: "Generated summary from the watch event." }), + 750, + ), + ]), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const summaryPanel = await canvas.findByRole("tabpanel", { + name: "Summary", + }); + const summary = within(summaryPanel); + expect(await summary.findByText("No summary yet.")).toBeVisible(); + + expect( + await summary.findByText( + "Generated summary from the watch event.", + {}, + { timeout: 5_000 }, + ), + ).toBeVisible(); + expect(summary.queryByRole("status")).not.toBeInTheDocument(); + }, +}; + +export const SummaryReconnectRefreshesPersistedSummary: Story = { + decorators: [withProxyProvider()], + beforeEach: () => { + mockChats([watchedChat()]); + spyOn(API.experimental, "getChat").mockResolvedValue(watchedChat()); + const cleanup = mockAgentChatPageAPIs(); + clearPersistedSidebarTabId(WATCHED_CHAT_ID); + localStorage.setItem(RIGHT_PANEL_OPEN_KEY, "true"); + return () => { + clearPersistedSidebarTabId(WATCHED_CHAT_ID); + cleanup(); + }; + }, + parameters: watchedChatPageParameters(watchedChat(), [ + { event: "open", connectionIndex: 0 }, + { event: "close", delayMs: 3_000, connectionIndex: 0 }, + { event: "open", connectionIndex: 1 }, + ]), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const summaryPanel = await canvas.findByRole("tabpanel", { + name: "Summary", + }); + const summary = within(summaryPanel); + expect(await summary.findByText("No summary yet.")).toBeVisible(); + const getChatMock = mocked(API.experimental.getChat); + getChatMock.mockResolvedValue( + watchedChat({ summary: "Summary completed while disconnected." }), + ); + const callsBeforeReconnect = getChatMock.mock.calls.length; + expect( + await summary.findByText( + "Summary completed while disconnected.", + {}, + { timeout: 5_000 }, + ), + ).toBeVisible(); + expect(getChatMock.mock.calls.length).toBeGreaterThan(callsBeforeReconnect); + expect(summary.queryByRole("status")).not.toBeInTheDocument(); + }, +}; + export const ArchiveWatchEventKeepsOpenChatMounted: Story = { decorators: [withProxyProvider()], beforeEach: () => { diff --git a/site/src/pages/AgentsPage/AgentsPageLayout.tsx b/site/src/pages/AgentsPage/AgentsPageLayout.tsx index 052ea683101..2ba5b8d7e41 100644 --- a/site/src/pages/AgentsPage/AgentsPageLayout.tsx +++ b/site/src/pages/AgentsPage/AgentsPageLayout.tsx @@ -567,12 +567,14 @@ const AgentsPageLayout: FC = () => { } const chatEvent = event.parsedMessage; const updatedChat = chatEvent.chat; - // The old membership is only available before the cache write below. - const prevStatus = readInfiniteChatsCache(queryClient)?.find( - (chat) => chat.id === updatedChat.id, - )?.status; - // Only play the chime for top-level chats, not sub-agents. - if (!updatedChat.parent_chat_id) { + if ( + chatEvent.kind === "status_change" && + !updatedChat.parent_chat_id + ) { + // The old membership is only available before the cache write below. + const prevStatus = readInfiniteChatsCache(queryClient)?.find( + (chat) => chat.id === updatedChat.id, + )?.status; maybePlayChime( prevStatus, updatedChat.status, @@ -673,6 +675,19 @@ const AgentsPageLayout: FC = () => { return ws; }, onOpen() { + const activeChatId = activeChatIDRef.current; + if (activeChatId) { + void invalidateChatEntity(queryClient, activeChatId); + const activeChat = queryClient.getQueryData( + chatEntityKey(activeChatId), + ); + if (activeChat) { + const costChatId = getChatCostTreeID(activeChat); + if (costChatId) { + void invalidateChatCostTree(queryClient, costChatId); + } + } + } void invalidateChatListQueries(queryClient); void invalidateChatsByWorkspace(queryClient); void invalidateChatSearches(queryClient); diff --git a/site/src/pages/AgentsPage/components/ChatSummary.stories.tsx b/site/src/pages/AgentsPage/components/ChatSummary.stories.tsx index d680df898e7..0e1a0a79fc6 100644 --- a/site/src/pages/AgentsPage/components/ChatSummary.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatSummary.stories.tsx @@ -2,12 +2,29 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, within } from "storybook/test"; import { ChatSummary } from "./ChatSummary"; +const MARKDOWN_SUMMARY = [ + "Investigated the flaky CI job in `coderd/x/chatd` and landed a fix.", + "", + "- Traced the failure to a cache-layer race in `chatd.go`", + "- Added a regression test covering the race", + "- Opened PR #26649", +].join("\n"); + +const LONG_SUMMARY = [ + "Audited the whole chat pipeline and shipped a batch of fixes.", + "", + ...Array.from( + { length: 12 }, + (_, i) => + `- Reviewed subsystem number ${i + 1} and applied the corresponding fix so the behaviour matches the specification`, + ), +].join("\n"); + 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.", + summary: MARKDOWN_SUMMARY, createdAt: "2024-05-01T12:00:00Z", updatedAt: "2024-05-02T15:30:00Z", costMicros: 1_250_000, @@ -15,7 +32,7 @@ const meta: Meta = { }, decorators: [ (Story) => ( -
+
), @@ -39,11 +56,119 @@ export const WithSummary: Story = { }, }; +export const HeadlineAndBullets: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByText(/Investigated the flaky CI job/), + ).toBeInTheDocument(); + + const list = canvas.getByRole("list"); + await expect(within(list).getAllByRole("listitem")).toHaveLength(3); + + await expect(canvas.getByText("chatd.go")).toBeInTheDocument(); + await expect(canvas.queryByText(/`/)).not.toBeInTheDocument(); + }, +}; + +// Headline-only summaries: legacy prose, subagent report snippets, and +// trivial chats whose headline covers everything. +export const HeadlineOnlySummary: Story = { + args: { + summary: + "Investigated the flaky CI job, traced it to a race in the cache layer, and added a regression test.", + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByText(/traced it to a race in the cache layer/), + ).toBeInTheDocument(); + await expect(canvas.queryByRole("list")).not.toBeInTheDocument(); + }, +}; + +// A prose summary starting with "1. " parses as an ordered list; `ol` is +// allowlisted so the items keep a list parent. +export const LegacyOrderedList: Story = { + args: { summary: "1. Fixed the race\n2. Added a test" }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const list = canvas.getByRole("list"); + await expect(list.tagName).toBe("OL"); + await expect(within(list).getAllByRole("listitem")).toHaveLength(2); + }, +}; + +export const LinksRenderAsPlainText: Story = { + args: { + summary: + "Investigated the failure in [PR #26649](https://example.com/pr) and fixed it.", + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText(/PR #26649/)).toBeInTheDocument(); + await expect(canvas.queryByRole("link")).not.toBeInTheDocument(); + }, +}; + +// A single backticked identifier can be wider than the panel and must wrap. +export const LongIdentifierWraps: Story = { + args: { + summary: + "Fixed `TestValidateGeneratedChatSummaryHeadlineExceedsBothTheRuneAndSentenceCaps` in `coderd/x/chatd/summarygen_internal_test.go`.", + }, + // Narrower than the panel minimum so the identifier cannot fit on one + // line. Layout is covered by visual snapshots; per FE10 the assertion + // stays semantic. + decorators: [ + (Story) => ( +
+ +
+ ), + ], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByText( + "TestValidateGeneratedChatSummaryHeadlineExceedsBothTheRuneAndSentenceCaps", + ), + ).toBeVisible(); + }, +}; + +// Renders at natural height; the surrounding panel scrolls instead of +// clipping or collapsing. +export const LongSummary: Story = { + args: { summary: LONG_SUMMARY }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const list = canvas.getByRole("list"); + await expect(within(list).getAllByRole("listitem")).toHaveLength(12); + await expect(within(list).getByText(/subsystem number 12/)).toBeVisible(); + }, +}; + export const NoSummary: Story = { args: { summary: null }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect(canvas.getByText("No summary yet.")).toBeInTheDocument(); + await expect( + canvas.getByText("A recap of this chat will appear here when available."), + ).toBeInTheDocument(); + await expect(canvas.getByText("Created:")).toBeInTheDocument(); + await expect(canvas.getByText("Updated:")).toBeInTheDocument(); + await expect(canvas.getByText("Cost:")).toBeInTheDocument(); + await expect(canvas.getByText("$1.25")).toBeInTheDocument(); }, }; diff --git a/site/src/pages/AgentsPage/components/ChatSummary.tsx b/site/src/pages/AgentsPage/components/ChatSummary.tsx index 41ca7328bcf..4e7688871bc 100644 --- a/site/src/pages/AgentsPage/components/ChatSummary.tsx +++ b/site/src/pages/AgentsPage/components/ChatSummary.tsx @@ -1,4 +1,6 @@ +import { MessageSquareDashedIcon } from "lucide-react"; import type { FC, ReactNode } from "react"; +import { InlineMarkdown } from "#/components/Markdown/InlineMarkdown"; import { Skeleton } from "#/components/Skeleton/Skeleton"; import { formatCostMicros } from "#/utils/currency"; import { DATE_FORMAT, formatDateTime } from "#/utils/time"; @@ -38,18 +40,18 @@ export const ChatSummary: FC = ({ hasCost && unpricedRequestCount != null && unpricedRequestCount > 0; return ( -
+
{trimmedSummary ? ( -

- {trimmedSummary} -

- ) : ( + + ) : isSubagent ? (

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

+ ) : ( + )} -
+
{formatDateTime(createdAt, DATE_FORMAT.MEDIUM_DATE)} @@ -88,6 +90,67 @@ export const ChatSummary: FC = ({ ); }; +const ChatSummaryEmpty: FC = () => ( +
+
+ +
+

+ No summary yet. +

+

+ A recap of this chat will appear here when available. +

+
+); + +interface ChatSummaryBodyProps { + summary: string; +} + +/** + * Height is deliberately unbounded: summaries are capped server-side and the + * surrounding panel already scrolls, so clamping would only add a second, + * worse overflow mechanism. + */ +const ChatSummaryBody: FC = ({ summary }) => ( +
+

{children}

, + ul: ({ children }) => ( +
    + {children} +
+ ), + ol: ({ children }) => ( +
    + {children} +
+ ), + li: ({ children }) =>
  • {children}
  • , + // A summary describes the chat rather than linking out of it, so + // model-authored URLs render as plain text. + a: ({ children }) => children, + }} + > + {summary} +
    +
    +); + interface ChatSummaryRowProps { label: string; children: ReactNode; diff --git a/site/src/pages/AgentsPage/components/ChatSummaryPanel.stories.tsx b/site/src/pages/AgentsPage/components/ChatSummaryPanel.stories.tsx index 896ee03d98f..a52154eab36 100644 --- a/site/src/pages/AgentsPage/components/ChatSummaryPanel.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatSummaryPanel.stories.tsx @@ -68,20 +68,41 @@ const meta: Meta = { export default meta; type Story = StoryObj; +export const Loading: Story = { + beforeEach: () => { + spyOn(API.experimental, "getChat").mockImplementation( + () => new Promise(() => {}), + ); + spyOn(API.experimental, "getChatCost").mockResolvedValue(mockCost); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByLabelText("Loading summary")).toBeVisible(); + expect(API.experimental.getChatCost).not.toHaveBeenCalled(); + }, +}; + export const WithSummary: Story = { beforeEach: () => mockRequests({ - summary: - "Investigated the flaky CI job, traced it to a cache-layer race, and added a regression test.", + summary: [ + "Investigated the flaky CI job and landed a fix.", + "", + "- Traced it to a cache-layer race in `chatd.go`", + "- Added a regression test covering the race", + ].join("\n"), }), play: async ({ canvasElement }) => { const canvas = within(canvasElement); await waitFor(() => { expect( - canvas.getByText(/traced it to a cache-layer race/), + canvas.getByText(/Traced it to a cache-layer race/), ).toBeInTheDocument(); expect(canvas.getByText("$1.25")).toBeInTheDocument(); }); + expect( + within(canvas.getByRole("list")).getAllByRole("listitem"), + ).toHaveLength(2); }, }; @@ -144,6 +165,23 @@ export const NotVisible: Story = { }, }; +export const NoSummary: Story = { + beforeEach: () => mockRequests(), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await waitFor(() => { + expect(canvas.getByText("No summary yet.")).toBeInTheDocument(); + }); + expect( + canvas.getByText("A recap of this chat will appear here when available."), + ).toBeInTheDocument(); + expect(canvas.getByText("Created:")).toBeInTheDocument(); + expect(canvas.getByText("Updated:")).toBeInTheDocument(); + expect(canvas.getByText("Cost:")).toBeInTheDocument(); + expect(canvas.getByText("$1.25")).toBeInTheDocument(); + }, +}; + export const GatewayUnavailable: Story = { parameters: { features: [] }, beforeEach: () => mockRequests({ summary: "Gateway is off here." }), diff --git a/site/src/pages/AgentsPage/components/ChatSummaryPanel.tsx b/site/src/pages/AgentsPage/components/ChatSummaryPanel.tsx index 7f4e0b0bede..66a5a1615a5 100644 --- a/site/src/pages/AgentsPage/components/ChatSummaryPanel.tsx +++ b/site/src/pages/AgentsPage/components/ChatSummaryPanel.tsx @@ -2,6 +2,7 @@ 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 { Skeleton } from "#/components/Skeleton/Skeleton"; import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility"; import { getChatCostTreeID } from "./ChatConversation/chatHelpers"; import { ChatSummary } from "./ChatSummary"; @@ -27,8 +28,20 @@ export const ChatSummaryPanel: FC = ({ }); let content: ReactNode = null; - if (chatQuery.isError) { - content = ; + if (chatQuery.isLoading) { + content = ( +
    + + + +
    + ); + } else if (chatQuery.isError) { + content = ( +
    + +
    + ); } else if (chatData) { content = ( = ({ } return ( -
    +
    {content}
    ); diff --git a/site/src/testHelpers/storybook.tsx b/site/src/testHelpers/storybook.tsx index 0a4d3f7c1ec..78242cdc9b1 100644 --- a/site/src/testHelpers/storybook.tsx +++ b/site/src/testHelpers/storybook.tsx @@ -88,6 +88,9 @@ type CallbackFn = (ev?: MessageEvent) => void; // "/api/v2/chats/": [{ event: "message", data: "..." }], // "/api/experimental/workspaceagents/": [{ event: "message", data: "..." }], // } +// +// Events may set delayMs to defer delivery after listeners are registered. +// connectionIndex targets the zero-based socket created for a route. export const withWebSocket = (Story: FC, { parameters }: StoryContext) => { const param = parameters.webSocket; @@ -99,6 +102,7 @@ export const withWebSocket = (Story: FC, { parameters }: StoryContext) => { const isRouted = !Array.isArray(param); const broadcastEvents = isRouted ? [] : param; const routedEvents = isRouted ? param : {}; + const connectionCounts = new Map(); window.WebSocket = class WebSocket { public readyState = 1; @@ -107,10 +111,20 @@ export const withWebSocket = (Story: FC, { parameters }: StoryContext) => { #listeners = new Map(); #callEventsDelay: number | undefined; + #connectionIndex: number; + #routeKey: string | undefined; #url: string; constructor(url?: string) { this.#url = url ?? ""; + this.#routeKey = isRouted + ? Object.keys(routedEvents).find((key) => this.#url.includes(key)) + : undefined; + const connectionCountKey = isRouted + ? (this.#routeKey ?? this.#url) + : "broadcast"; + this.#connectionIndex = connectionCounts.get(connectionCountKey) ?? 0; + connectionCounts.set(connectionCountKey, this.#connectionIndex + 1); } send() {} @@ -121,11 +135,13 @@ export const withWebSocket = (Story: FC, { parameters }: StoryContext) => { // Determine which events this socket should receive. let events = broadcastEvents; if (isRouted) { - const matchingKey = Object.keys(routedEvents).find((key) => - this.#url.includes(key), - ); - events = matchingKey ? routedEvents[matchingKey] : []; + events = this.#routeKey ? routedEvents[this.#routeKey] : []; } + events = events.filter( + (entry) => + entry.connectionIndex === undefined || + entry.connectionIndex === this.#connectionIndex, + ); if (events.length === 0) { return; @@ -135,13 +151,16 @@ export const withWebSocket = (Story: FC, { parameters }: StoryContext) => { clearTimeout(this.#callEventsDelay); this.#callEventsDelay = window.setTimeout(() => { for (const entry of events) { - const callback = this.#listeners.get(entry.event); + const dispatch = () => { + const callback = this.#listeners.get(entry.event); - if (callback) { - entry.event === "message" - ? callback({ data: entry.data }) - : callback(); - } + if (callback) { + entry.event === "message" + ? callback({ data: entry.data }) + : callback(); + } + }; + window.setTimeout(dispatch, entry.delayMs ?? 0); } }, 0); }