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",