From e6a9b59abefe81f95b8fe973c5ae6a175dbdc045 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 22 Jun 2026 11:09:39 -0600 Subject: [PATCH] feat(site/src): surface pinned chat workspace context in the UI (#26573) Adds the workspace-context indicator UI for agent chats, part of breaking the "Workspace Context Sources for Coder Agents" RFC (#26466) into small, reviewable PRs. ## What this adds - **Pinned context popover**: the context-usage indicator now lists the chat's pinned resources, instruction files, skills, and MCP servers with their tools. Unusable resources (invalid skill, unreadable/oversize file) are surfaced in an "Issues" section with their error rather than dropped silently. - **Drift and error states**: when the pinned context differs from the agent's latest snapshot, the ring shows a warning marker and the popover explains the drift. A snapshot-level error gets a distinct treatment. - **Refresh context**: a button re-pins the chat to the agent's latest snapshot via `PUT /api/experimental/chats/{id}/context`. - **Live updates**: `context_dirty` watch events apply the lightweight dirty flags across the cached chat lists and refetch the open chat so the full pinned detail loads. ## Not included The context **changes/diff** view (the "View changes" affordance and its dialog) is intentionally deferred to a later split, so this PR contains no diff rendering. ## Testing - `pnpm lint` (types, biome, circular deps, React Compiler, knip) - `pnpm test src/api/queries/chats.test.ts` (cache-merge unit tests, including the new `context_dirty` cases) - `pnpm test:storybook src/pages/AgentsPage/components/ContextUsageIndicator.stories.tsx` (4 stories) - `pnpm format`
Design notes This is **Split 3** of #26466. Split sequence: 1. #26558 - prompt pin consumption (merged) 2. #26570 - `codersdk` context resource types (merged). During review the type was renamed `ChatContextMCPTool` -> `ChatContextTool` and the field `mcp_tools` -> `tools`; this PR consumes those merged names. 3. **This PR** - the UI. 4. CLI `coder exp chat context` source CRUD + `refresh` (next). 5. The context diff (`changes`, `ChatContextResourceChange`, the changes dialog, and `buildContentPatch`) - last. Key decisions: - The backend already publishes `ChatWatchEventKindContextDirty` and exposes the refresh endpoint (#26389). Watch/pubsub payloads stay lightweight: they carry only the `dirty`/`dirty_since`/`error` flags and omit `resources`. So `mergeWatchedChatSummary` merges (not replaces) the cached context to preserve the pinned `resources` a single-chat GET populated, and the `AgentsPage` watch handler refetches only the open chat to pull the full pinned detail. - The indicator prefers the chat's pinned `resources`; while they have not loaded it falls back to the agent's `last_injected_context`, skipping the empty context-file placeholder so it never renders a nameless row. - All diff/changes rendering is excluded here and lands in the final split to keep this PR focused on the read-only pinned view and the refresh action.
*This PR was created by Coder Agents on behalf of @kylecarbs.* --- site/src/api/api.ts | 11 + site/src/api/queries/chats.test.ts | 62 ++++ site/src/api/queries/chats.ts | 47 ++- site/src/pages/AgentsPage/AgentChatPage.tsx | 1 + .../pages/AgentsPage/AgentChatPageView.tsx | 3 + site/src/pages/AgentsPage/AgentsPage.tsx | 12 + .../AgentsPage/components/AgentChatInput.tsx | 13 +- .../AgentsPage/components/ChatPageContent.tsx | 33 +- .../ContextUsageIndicator.stories.tsx | 145 ++++++++ .../components/ContextUsageIndicator.tsx | 347 +++++++++++++++--- site/src/testHelpers/chatEntities.ts | 67 ++++ 11 files changed, 689 insertions(+), 52 deletions(-) create mode 100644 site/src/pages/AgentsPage/components/ContextUsageIndicator.stories.tsx diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 32fffce83a..cb97226c5d 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -3420,6 +3420,17 @@ class ExperimentalApiMethods { return response.data; }; + /** + * Re-pins the chat to its agent's latest context snapshot and clears + * the dirty marker. Returns the updated chat. + */ + refreshChatContext = async (chatId: string): Promise => { + const response = await this.axios.put( + `/api/experimental/chats/${chatId}/context`, + ); + return response.data; + }; + deleteChatQueuedMessage = async ( chatId: string, queuedMessageId: number, diff --git a/site/src/api/queries/chats.test.ts b/site/src/api/queries/chats.test.ts index 81890268de..310d4f78d9 100644 --- a/site/src/api/queries/chats.test.ts +++ b/site/src/api/queries/chats.test.ts @@ -2062,6 +2062,68 @@ describe("updateChildInParentCache", () => { }); describe("mergeWatchedChatSummary", () => { + it("applies context_dirty flags while preserving the pinned resource list", () => { + const cachedChat = makeChat("chat-1", { + updated_at: "2025-01-01T00:00:00.000Z", + context: { + dirty: false, + resources: [ + { + source: "/AGENTS.md", + kind: "instruction_file", + size_bytes: 10, + status: "ok", + }, + ], + }, + }); + const watchedChat = makeChat("chat-1", { + // Drift is tracked outside updated_at, so an older event timestamp + // still applies the dirty flags. + updated_at: "2024-12-31T00:00:00.000Z", + context: { dirty: true, dirty_since: "2025-01-02T00:00:00.000Z" }, + }); + + expect( + mergeWatchedChatSummary(cachedChat, watchedChat, { + eventKind: "context_dirty", + }).context, + ).toEqual({ + dirty: true, + dirty_since: "2025-01-02T00:00:00.000Z", + // The lightweight watch payload omits resources; the merge keeps the + // pinned list a prior single-chat GET populated. + resources: [ + { + source: "/AGENTS.md", + kind: "instruction_file", + size_bytes: 10, + status: "ok", + }, + ], + }); + }); + + it("leaves context untouched for non-context events", () => { + const context = { dirty: true, dirty_since: "2025-01-02T00:00:00.000Z" }; + const cachedChat = makeChat("chat-1", { + status: "pending", + updated_at: "2025-01-01T00:00:00.000Z", + context, + }); + const watchedChat = makeChat("chat-1", { + status: "running", + updated_at: "2025-01-01T00:05:00.000Z", + context: { dirty: false }, + }); + + expect( + mergeWatchedChatSummary(cachedChat, watchedChat, { + eventKind: "status_change", + }).context, + ).toBe(context); + }); + it("merges fresh status updates without clobbering a newer title snapshot", () => { const cachedChat = makeChat("chat-1", { status: "pending", diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index 28a9c62aa7..a9acf46c5d 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -310,6 +310,7 @@ export const mergeWatchedChatSummary = ( const isStatusEvent = eventKind === "status_change"; const isSummaryEvent = eventKind === "summary_change"; const isDiffStatusEvent = eventKind === "diff_status_change"; + const isContextDirtyEvent = eventKind === "context_dirty"; const updatedAtComparison = compareUpdatedAtInstants( cachedChat.updated_at, watchedChat.updated_at, @@ -325,6 +326,15 @@ export const mergeWatchedChatSummary = ( const nextDiffStatus = isDiffStatusEvent ? watchedChat.diff_status : cachedChat.diff_status; + // Context drift is tracked outside chats.updated_at (it is driven by + // agent context pushes), so apply context_dirty payloads regardless of + // the summary timestamp. Merge rather than replace so the pinned + // resources a single-chat GET populated are preserved while the dirty + // flags update; the open chat refetches the full detail. + const nextContext = + isContextDirtyEvent && watchedChat.context + ? { ...cachedChat.context, ...watchedChat.context } + : cachedChat.context; const nextWorkspaceId = isFreshEnough ? (watchedChat.workspace_id ?? cachedChat.workspace_id) : cachedChat.workspace_id; @@ -358,7 +368,8 @@ export const mergeWatchedChatSummary = ( nextLastModelConfigId === cachedChat.last_model_config_id && nextLastTurnSummary === cachedChat.last_turn_summary && nextHasUnread === cachedChat.has_unread && - nextUpdatedAt === cachedChat.updated_at + nextUpdatedAt === cachedChat.updated_at && + nextContext === cachedChat.context ) { return cachedChat; } @@ -374,6 +385,7 @@ export const mergeWatchedChatSummary = ( last_turn_summary: nextLastTurnSummary, has_unread: nextHasUnread, updated_at: nextUpdatedAt, + context: nextContext, }; }; @@ -1344,6 +1356,39 @@ export const interruptChat = (queryClient: QueryClient, chatId: string) => ({ }, }); +/** + * Re-pins the chat to its agent's latest context snapshot, clearing the + * dirty marker. On success the returned chat (carrying the freshly pinned + * resources) is written into the open-chat cache, and the lightweight + * context flags are propagated across the list caches so the dirty + * indicator clears in the sidebar too. + */ +export const refreshChatContext = ( + queryClient: QueryClient, + chatId: string, +) => ({ + mutationFn: () => API.experimental.refreshChatContext(chatId), + onSuccess: (updatedChat: TypesGen.Chat) => { + queryClient.setQueryData(chatKey(chatId), (cached) => + cached ? { ...cached, context: updatedChat.context } : updatedChat, + ); + const applyContext = (chat: TypesGen.Chat): TypesGen.Chat => + chat.id === chatId ? { ...chat, context: updatedChat.context } : chat; + updateInfiniteChatsCache(queryClient, (chats) => { + let changed = false; + const next = chats.map((chat) => { + const updated = applyContext(chat); + if (updated !== chat) { + changed = true; + } + return updated; + }); + return changed ? next : chats; + }); + updateChildInParentCache(queryClient, applyContext, chatId); + }, +}); + export const deleteChatQueuedMessage = ( queryClient: QueryClient, chatId: string, diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 3c6f85e725..80aa853169 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -1674,6 +1674,7 @@ const AgentChatPage: FC = () => { onMCPSelectionChange={handleMCPSelectionChange} onMCPAuthComplete={handleMCPAuthComplete} lastInjectedContext={chatQuery.data?.last_injected_context} + chatContext={chatQuery.data?.context} /> ); }; diff --git a/site/src/pages/AgentsPage/AgentChatPageView.tsx b/site/src/pages/AgentsPage/AgentChatPageView.tsx index bf23e0b4fa..46b7879bf2 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.tsx @@ -216,6 +216,7 @@ interface AgentChatPageViewProps { desktopChatId?: string; lastInjectedContext?: readonly TypesGen.ChatMessagePart[]; + chatContext?: TypesGen.ChatContext; } const UnavailableTabMessage: FC<{ message: string }> = ({ message }) => ( @@ -373,6 +374,7 @@ export const AgentChatPageView: FC = ({ onMCPAuthComplete, desktopChatId, lastInjectedContext, + chatContext, }) => { const queryClient = useQueryClient(); const { proxy } = useProxy(); @@ -964,6 +966,7 @@ export const AgentChatPageView: FC = ({ onMCPSelectionChange={onMCPSelectionChange} onMCPAuthComplete={onMCPAuthComplete} lastInjectedContext={lastInjectedContext} + chatContext={chatContext} workspace={workspace} workspaceAgent={workspaceAgent} chatId={agentId} diff --git a/site/src/pages/AgentsPage/AgentsPage.tsx b/site/src/pages/AgentsPage/AgentsPage.tsx index 0584a6bf64..fc0c8af888 100644 --- a/site/src/pages/AgentsPage/AgentsPage.tsx +++ b/site/src/pages/AgentsPage/AgentsPage.tsx @@ -645,6 +645,18 @@ const AgentsPage: FC = () => { if (shouldInvalidateFilteredChatList(updatedChat, chatEvent.kind)) { void invalidateChatListQueries(queryClient); } + if (chatEvent.kind === "context_dirty") { + // The watch payload carries only the lightweight + // context flags (the merge above applies them); + // refetch the open chat to pull the pinned + // resources the single-chat GET computes. Only the + // active chat has an observer, so other chats are + // merely marked stale. + void queryClient.invalidateQueries({ + queryKey: chatKey(updatedChat.id), + exact: true, + }); + } } }); return ws; diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.tsx index ea6f59dc5f..809e829c6f 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.tsx @@ -162,6 +162,11 @@ interface AgentChatInputProps { // Pass `null` to render fallback values (e.g. when limit is unknown). // Omit entirely to hide the indicator. contextUsage?: AgentContextUsage | null; + // Re-pins the chat to the workspace's latest context snapshot, + // surfaced by the context indicator when the pinned context has + // drifted. + onRefreshContext?: () => void; + isRefreshingContext?: boolean; attachments?: readonly File[]; onAttach?: (files: File[]) => void; onRemoveAttachment?: (attachment: number | File) => void; @@ -367,6 +372,8 @@ export const AgentChatInput: FC = ({ onCancelHistoryEdit, userPromptHistory = [], contextUsage, + onRefreshContext, + isRefreshingContext, attachments = [], onAttach, onRemoveAttachment, @@ -1537,7 +1544,11 @@ export const AgentChatInput: FC = ({ )} {contextUsage !== undefined && ( - + )} {isStreaming && onInterrupt && ( + + )} )} @@ -225,6 +471,17 @@ export const ContextUsageIndicator: FC<{ usage: AgentContextUsage | null }> = ({ progressClassName="stroke-current" className={cn("size-icon-sm", toneClassName)} /> + {(isDirty || hasContextError) && ( + + )} ); diff --git a/site/src/testHelpers/chatEntities.ts b/site/src/testHelpers/chatEntities.ts index cba8d71759..54a4fa58b4 100644 --- a/site/src/testHelpers/chatEntities.ts +++ b/site/src/testHelpers/chatEntities.ts @@ -1,6 +1,9 @@ import type { Chat, + ChatContext, + ChatContextResource, ChatMessage, + ChatMessagePart, ChatQueuedMessage, MCPServerConfig, } from "#/api/typesGenerated"; @@ -30,6 +33,70 @@ export const MockChat: Chat = { children: [], }; +// Pinned workspace-context resources the prompt is built from. +const MockChatContextResources: ChatContextResource[] = [ + { + source: "/home/coder/AGENTS.md", + kind: "instruction_file", + size_bytes: 248, + status: "ok", + }, + { + source: "/home/coder/.coder/skills/deploy", + kind: "skill", + size_bytes: 96, + status: "ok", + skill_name: "deploy", + skill_description: "Deploy the app to staging.", + }, + { + source: "/home/coder/.mcp.json", + kind: "mcp_config", + size_bytes: 184, + status: "ok", + }, + { + source: "github", + kind: "mcp_server", + size_bytes: 512, + status: "ok", + tools: [ + { + name: "search_issues", + description: "Search issues and pull requests.", + }, + { name: "create_issue", description: "Open a new issue." }, + ], + }, + { + // An invalid skill the agent rejected: surfaced as an issue with its + // error rather than silently dropped. + source: "/home/coder/test/.agents/skills/moo", + kind: "skill", + size_bytes: 356, + status: "invalid", + error: 'front-matter name "coder-review" does not match directory "moo"', + }, +]; + +export const MockChatContextClean: ChatContext = { + dirty: false, + resources: MockChatContextResources, +}; + +export const MockChatContextDirty: ChatContext = { + dirty: true, + dirty_since: "2024-01-02T00:00:00Z", + resources: MockChatContextResources, +}; + +// Injected-context fallback whose only context-file marker has no path. The +// agent emits this empty placeholder for skill-only additions; the context +// indicator must skip it rather than render a nameless "Context files" row. +export const MockLastInjectedContextEmptyFile: readonly ChatMessagePart[] = [ + { type: "context-file", context_file_path: "" }, +]; + export const MockMCPServerConfig: MCPServerConfig = { id: "mcp-1", display_name: "MCP Server",