diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 28d7514c77..862ab67cd9 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -3198,6 +3198,63 @@ class ApiMethods { const response = await this.axios.get(url); return response.data; }; + + getChatUsageLimitConfig = + async (): Promise => { + const response = + await this.axios.get( + "/api/experimental/chats/usage-limits", + ); + return response.data; + }; + + updateChatUsageLimitConfig = async ( + req: TypesGen.ChatUsageLimitConfig, + ): Promise => { + const response = await this.axios.put( + "/api/experimental/chats/usage-limits", + req, + ); + return response.data; + }; + + upsertChatUsageLimitOverride = async ( + userID: string, + req: TypesGen.UpsertChatUsageLimitOverrideRequest, + ): Promise => { + const response = await this.axios.put( + `/api/experimental/chats/usage-limits/overrides/${encodeURIComponent(userID)}`, + req, + ); + return response.data; + }; + + deleteChatUsageLimitOverride = async (userID: string): Promise => { + const response = await this.axios.delete( + `/api/experimental/chats/usage-limits/overrides/${encodeURIComponent(userID)}`, + ); + return response.data; + }; + + upsertChatUsageLimitGroupOverride = async ( + groupID: string, + req: TypesGen.UpsertChatUsageLimitGroupOverrideRequest, + ): Promise => { + const response = await this.axios.put( + `/api/experimental/chats/usage-limits/group-overrides/${encodeURIComponent(groupID)}`, + req, + ); + return response.data; + }; + + deleteChatUsageLimitGroupOverride = async ( + groupID: string, + ): Promise => { + const response = await this.axios.delete( + `/api/experimental/chats/usage-limits/group-overrides/${encodeURIComponent(groupID)}`, + ); + return response.data; + }; } export type TaskFeedbackRating = "good" | "okay" | "bad"; diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index 49500c55e5..d6913cc2e0 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -530,3 +530,79 @@ export const chatCostUsers = (params?: ChatCostUsersParams) => ({ queryFn: () => API.getChatCostUsers(params), staleTime: 60_000, }); + +export const chatUsageLimitConfigKey = [ + ...chatsKey, + "usageLimitConfig", +] as const; + +export const chatUsageLimitConfig = () => ({ + queryKey: chatUsageLimitConfigKey, + queryFn: () => API.getChatUsageLimitConfig(), +}); + +export const updateChatUsageLimitConfig = (queryClient: QueryClient) => ({ + mutationFn: (req: TypesGen.ChatUsageLimitConfig) => + API.updateChatUsageLimitConfig(req), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: chatUsageLimitConfigKey, + }); + }, +}); + +type UpsertChatUsageLimitOverrideMutationArgs = { + userID: string; + req: TypesGen.UpsertChatUsageLimitOverrideRequest; +}; + +export const upsertChatUsageLimitOverride = (queryClient: QueryClient) => ({ + mutationFn: ({ userID, req }: UpsertChatUsageLimitOverrideMutationArgs) => + API.upsertChatUsageLimitOverride(userID, req), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: chatUsageLimitConfigKey, + }); + }, +}); + +export const deleteChatUsageLimitOverride = (queryClient: QueryClient) => ({ + mutationFn: (userID: string) => API.deleteChatUsageLimitOverride(userID), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: chatUsageLimitConfigKey, + }); + }, +}); + +type UpsertChatUsageLimitGroupOverrideMutationArgs = { + groupID: string; + req: TypesGen.UpsertChatUsageLimitGroupOverrideRequest; +}; + +export const upsertChatUsageLimitGroupOverride = ( + queryClient: QueryClient, +) => ({ + mutationFn: ({ + groupID, + req, + }: UpsertChatUsageLimitGroupOverrideMutationArgs) => + API.upsertChatUsageLimitGroupOverride(groupID, req), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: chatUsageLimitConfigKey, + }); + }, +}); + +export const deleteChatUsageLimitGroupOverride = ( + queryClient: QueryClient, +) => ({ + mutationFn: (groupID: string) => + API.deleteChatUsageLimitGroupOverride(groupID), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: chatUsageLimitConfigKey, + }); + }, +}); diff --git a/site/src/api/queries/groups.ts b/site/src/api/queries/groups.ts index 9e12ae8abf..4f5d7bc4c3 100644 --- a/site/src/api/queries/groups.ts +++ b/site/src/api/queries/groups.ts @@ -10,7 +10,8 @@ type GroupSortOrder = "asc" | "desc"; export const groupsQueryKey = ["groups"]; -const groups = () => { +/** @public */ +export const groups = () => { return { queryKey: groupsQueryKey, queryFn: () => API.getGroups(), diff --git a/site/src/components/ChatMessageInput/FileReferenceNode.tsx b/site/src/components/ChatMessageInput/FileReferenceNode.tsx index 0ab0196084..180f3c2e37 100644 --- a/site/src/components/ChatMessageInput/FileReferenceNode.tsx +++ b/site/src/components/ChatMessageInput/FileReferenceNode.tsx @@ -23,7 +23,7 @@ type SerializedFileReferenceNode = Spread< SerializedLexicalNode >; -export function FileReferenceChip({ +function FileReferenceChip({ fileName, startLine, endLine, diff --git a/site/src/pages/AgentsPage/AgentCreateForm.stories.tsx b/site/src/pages/AgentsPage/AgentCreateForm.stories.tsx index 84bfe528d1..63e66d8dcd 100644 --- a/site/src/pages/AgentsPage/AgentCreateForm.stories.tsx +++ b/site/src/pages/AgentsPage/AgentCreateForm.stories.tsx @@ -41,6 +41,8 @@ const meta: Meta = { export default meta; type Story = StoryObj; +const defaultArgs = meta.args; + export const Default: Story = {}; const mockWorkspaces = [ @@ -157,3 +159,29 @@ export const SelectWorkspaceViaSearch: Story = { }); }, }; + +export const UsageLimitExceeded: Story = { + args: { + ...defaultArgs, + createError: Object.assign( + new Error("Request failed with status code 409"), + { + isAxiosError: true, + response: { + status: 409, + statusText: "Conflict", + data: { + message: "Chat usage limit exceeded.", + spent_micros: 900_000, + limit_micros: 500_000, + resets_at: "2026-03-16T00:00:00Z", + }, + headers: {}, + config: {}, + }, + config: {}, + toJSON: () => ({}), + }, + ), + }, +}; diff --git a/site/src/pages/AgentsPage/AgentCreateForm.tsx b/site/src/pages/AgentsPage/AgentCreateForm.tsx index 50231cfb9a..f988b092da 100644 --- a/site/src/pages/AgentsPage/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/AgentCreateForm.tsx @@ -1,8 +1,11 @@ +import { isApiError } from "api/errors"; import { workspaces } from "api/queries/workspaces"; import type * as TypesGen from "api/typesGenerated"; +import { Alert } from "components/Alert/Alert"; import { ErrorAlert } from "components/Alert/ErrorAlert"; import { ChevronDownIcon } from "components/AnimatedIcons/ChevronDown"; import type { ModelSelectorOption } from "components/ai-elements"; +import { Button } from "components/Button/Button"; import { Combobox, ComboboxContent, @@ -30,6 +33,7 @@ import { getModelSelectorPlaceholder, hasConfiguredModelsInCatalog, } from "./modelOptions"; +import { formatUsageLimitMessage, isUsageLimitData } from "./usageLimitMessage"; import { useFileAttachments } from "./useFileAttachments"; /** @internal Exported for testing. */ @@ -110,6 +114,7 @@ interface AgentCreateFormProps { modelConfigs: readonly TypesGen.ChatModelConfig[]; isModelConfigsLoading: boolean; modelCatalogError: unknown; + onOpenAnalytics?: () => void; } export const AgentCreateForm: FC = ({ @@ -122,6 +127,7 @@ export const AgentCreateForm: FC = ({ isModelCatalogLoading, isModelConfigsLoading, modelCatalogError, + onOpenAnalytics, }) => { const { organizations } = useDashboard(); const { initialInputValue, handleContentChange, submitDraft, resetDraft } = @@ -326,7 +332,27 @@ export const AgentCreateForm: FC = ({ return (
- {createError ? : null} + {createError ? ( + isApiError(createError) && + createError.response?.status === 409 && + isUsageLimitData(createError.response.data) ? ( + + View Usage + + ) + } + > + {formatUsageLimitMessage(createError.response.data)} + + ) : ( + + ) + ) : null} {workspacesQuery.isError && ( )} diff --git a/site/src/pages/AgentsPage/AgentDetail.tsx b/site/src/pages/AgentsPage/AgentDetail.tsx index 72e7b10efd..9cfd557c3b 100644 --- a/site/src/pages/AgentsPage/AgentDetail.tsx +++ b/site/src/pages/AgentsPage/AgentDetail.tsx @@ -1,4 +1,5 @@ import { API, watchWorkspace } from "api/api"; +import { isApiError } from "api/errors"; import { chat, chatMessagesForInfiniteScroll, @@ -14,14 +15,12 @@ import { import { deploymentSSHConfig } from "api/queries/deployment"; import { workspaceById, workspaceByIdKey } from "api/queries/workspaces"; import type * as TypesGen from "api/typesGenerated"; -import type { ModelSelectorOption } from "components/ai-elements"; import { useProxy } from "contexts/ProxyContext"; import { getTerminalHref, getVSCodeHref, openAppInNewWindow, } from "modules/apps/apps"; -import { useDashboard } from "modules/dashboard/useDashboard"; import { type FC, useCallback, @@ -41,37 +40,9 @@ import { toast } from "sonner"; import type { UrlTransform } from "streamdown"; import { pageTitle } from "utils/page"; import { portForwardURL } from "utils/portForward"; -import { - AgentChatInput, - type ChatMessageInputRef, - type UploadState, -} from "./AgentChatInput"; -import { - selectChatStatus, - selectHasStreamState, - selectMessagesByID, - selectOrderedMessageIDs, - selectQueuedMessages, - selectRetryState, - selectStreamError, - selectStreamState, - selectSubagentStatusOverrides, - useChatSelector, - useChatStore, -} from "./AgentDetail/ChatContext"; -import { ConversationTimeline } from "./AgentDetail/ConversationTimeline"; -import { - getLatestContextUsage, - getParentChatID, - getWorkspaceAgent, -} from "./AgentDetail/chatHelpers"; -import { - buildParsedMessageSections, - buildSubagentTitles, - parseMessagesWithMergedTools, -} from "./AgentDetail/messageParsing"; -import { buildStreamTools } from "./AgentDetail/streamState"; -import { useMessageWindow } from "./AgentDetail/useMessageWindow"; +import type { ChatMessageInputRef } from "./AgentChatInput"; +import { useChatStore } from "./AgentDetail/ChatContext"; +import { getParentChatID, getWorkspaceAgent } from "./AgentDetail/chatHelpers"; import { useWorkspaceCreationWatcher } from "./AgentDetail/useWorkspaceCreationWatcher"; import { AgentDetailLoadingView, @@ -85,7 +56,7 @@ import { getModelSelectorPlaceholder, hasConfiguredModelsInCatalog, } from "./modelOptions"; -import { useFileAttachments } from "./useFileAttachments"; +import { formatUsageLimitMessage, isUsageLimitData } from "./usageLimitMessage"; import { useGitWatcher } from "./useGitWatcher"; /** localStorage key controlling whether the right panel is visible. */ @@ -96,309 +67,6 @@ const localHosts = new Set(["localhost", "127.0.0.1", "0.0.0.0"]); const lastModelConfigIDStorageKey = "agents.last-model-config-id"; /** @internal Exported for testing. */ export const draftInputStorageKeyPrefix = "agents.draft-input."; -type ChatStoreHandle = ReturnType["store"]; - -const isChatMessage = ( - message: TypesGen.ChatMessage | undefined, -): message is TypesGen.ChatMessage => Boolean(message); - -interface AgentDetailTimelineProps { - store: ChatStoreHandle; - chatID: string; - persistedErrorReason: string | undefined; - onEditUserMessage?: ( - messageId: number, - text: string, - fileBlocks?: readonly { mediaType: string; data?: string }[], - ) => void; - editingMessageId?: number | null; - savingMessageId?: number | null; - urlTransform?: UrlTransform; -} - -export const AgentDetailTimeline: FC = ({ - store, - chatID, - persistedErrorReason, - onEditUserMessage, - editingMessageId, - savingMessageId, - urlTransform, -}) => { - const messagesByID = useChatSelector(store, selectMessagesByID); - const orderedMessageIDs = useChatSelector(store, selectOrderedMessageIDs); - const streamState = useChatSelector(store, selectStreamState); - const chatStatus = useChatSelector(store, selectChatStatus); - const streamError = useChatSelector(store, selectStreamError); - const subagentStatusOverrides = useChatSelector( - store, - selectSubagentStatusOverrides, - ); - const retryState = useChatSelector(store, selectRetryState); - - const messages = useMemo( - () => - orderedMessageIDs - .map((messageID) => messagesByID.get(messageID)) - .filter(isChatMessage), - [messagesByID, orderedMessageIDs], - ); - const streamTools = useMemo( - () => buildStreamTools(streamState), - [streamState], - ); - const { hasMoreMessages, windowedMessages, loadMoreSentinelRef } = - useMessageWindow({ - messages, - resetKey: chatID, - }); - const parsedMessages = useMemo( - () => parseMessagesWithMergedTools(windowedMessages), - [windowedMessages], - ); - const subagentTitles = useMemo( - () => buildSubagentTitles(parsedMessages), - [parsedMessages], - ); - const parsedSections = useMemo( - () => buildParsedMessageSections(parsedMessages), - [parsedMessages], - ); - const detailErrorMessage = - (chatStatus === "error" ? persistedErrorReason : undefined) || streamError; - const latestMessage = messages[messages.length - 1]; - const latestMessageNeedsAssistantResponse = - !latestMessage || latestMessage.role !== "assistant"; - const isAwaitingFirstStreamChunk = - !streamState && - (chatStatus === "running" || chatStatus === "pending") && - latestMessageNeedsAssistantResponse; - const hasStreamOutput = Boolean(streamState) || isAwaitingFirstStreamChunk; - - return ( - - ); -}; - -interface AgentDetailInputProps { - store: ChatStoreHandle; - compressionThreshold: number | undefined; - onSend: (message: string, fileIds?: string[]) => void; - onDeleteQueuedMessage: (id: number) => Promise; - onPromoteQueuedMessage: (id: number) => Promise; - onInterrupt: () => void; - isInputDisabled: boolean; - isSendPending: boolean; - isInterruptPending: boolean; - hasModelOptions: boolean; - selectedModel: string; - onModelChange: (modelID: string) => void; - modelOptions: readonly ModelSelectorOption[]; - modelSelectorPlaceholder: string; - inputStatusText: string | null; - modelCatalogStatusMessage: string | null; - // Controlled input value and editing state, owned by the - // conversation component. - inputRef?: React.Ref; - initialValue?: string; - onContentChange?: (content: string) => void; - editingQueuedMessageID: number | null; - onStartQueueEdit: (id: number, text: string) => void; - onCancelQueueEdit: () => void; - isEditingHistoryMessage: boolean; - onCancelHistoryEdit: () => void; - // File blocks from the message being edited, converted to - // File objects and pre-populated into attachments. - editingFileBlocks?: readonly { - mediaType: string; - data?: string; - fileId?: string; - }[]; -} - -export const AgentDetailInput: FC = ({ - store, - compressionThreshold, - onSend, - onDeleteQueuedMessage, - onPromoteQueuedMessage, - onInterrupt, - isInputDisabled, - isSendPending, - isInterruptPending, - hasModelOptions, - selectedModel, - onModelChange, - modelOptions, - modelSelectorPlaceholder, - inputStatusText, - modelCatalogStatusMessage, - inputRef, - initialValue, - onContentChange, - editingQueuedMessageID, - onStartQueueEdit, - onCancelQueueEdit, - isEditingHistoryMessage, - onCancelHistoryEdit, - editingFileBlocks, -}) => { - const messagesByID = useChatSelector(store, selectMessagesByID); - const orderedMessageIDs = useChatSelector(store, selectOrderedMessageIDs); - const hasStreamState = useChatSelector(store, selectHasStreamState); - const chatStatus = useChatSelector(store, selectChatStatus); - const queuedMessages = useChatSelector(store, selectQueuedMessages); - - const messages = useMemo( - () => - orderedMessageIDs - .map((messageID) => messagesByID.get(messageID)) - .filter(isChatMessage), - [messagesByID, orderedMessageIDs], - ); - const { organizations } = useDashboard(); - const organizationId = organizations[0]?.id; - const latestContextUsage = useMemo(() => { - const usage = getLatestContextUsage(messages); - if (!usage) { - return usage; - } - return { ...usage, compressionThreshold }; - }, [messages, compressionThreshold]); - const { - attachments, - uploadStates, - previewUrls, - handleAttach, - handleRemoveAttachment, - resetAttachments, - setAttachments, - setPreviewUrls, - setUploadStates, - } = useFileAttachments(organizationId); - // Pre-populate attachments from existing file blocks when - // entering edit mode on a message with images. - useEffect(() => { - if (!editingFileBlocks || editingFileBlocks.length === 0) { - // Clear attachments when exiting edit mode. - setAttachments([]); - setUploadStates(new Map()); - setPreviewUrls(new Map()); - return; - } - const files = editingFileBlocks.map((block, i) => { - const ext = block.mediaType.split("/")[1] ?? "png"; - // Empty File used as a Map key only, its content is never - // read because the existing fileId is reused at send time. - return new File([], `attachment-${i}.${ext}`, { - type: block.mediaType, - }); - }); - setAttachments(files); - setPreviewUrls( - new Map( - files.map((f, i) => [ - f, - `/api/experimental/chats/files/${editingFileBlocks[i].fileId}`, - ]), - ), - ); - const newUploadStates = new Map(); - for (const [i, file] of files.entries()) { - const block = editingFileBlocks[i]; - if (block.fileId) { - newUploadStates.set(file, { - status: "uploaded", - fileId: block.fileId, - }); - } - } - setUploadStates(newUploadStates); - }, [editingFileBlocks, setAttachments, setPreviewUrls, setUploadStates]); - - const isStreaming = - hasStreamState || chatStatus === "running" || chatStatus === "pending"; - - return ( - { - void (async () => { - try { - // Collect file IDs from already-uploaded attachments. - // Skip files in error state (e.g. too large). - const fileIds: string[] = []; - let skippedErrors = 0; - for (const file of attachments) { - const state = uploadStates.get(file); - if (state?.status === "error") { - skippedErrors++; - continue; - } - if (state?.status === "uploaded" && state.fileId) { - fileIds.push(state.fileId); - } - } - if (skippedErrors > 0) { - toast.warning( - `${skippedErrors} attachment${skippedErrors > 1 ? "s" : ""} could not be sent (upload failed)`, - ); - } - await onSend(message, fileIds.length > 0 ? fileIds : undefined); - resetAttachments(); - } catch { - // Attachments preserved for retry on failure. - } - })(); - }} - attachments={attachments} - onAttach={handleAttach} - onRemoveAttachment={handleRemoveAttachment} - uploadStates={uploadStates} - previewUrls={previewUrls} - inputRef={inputRef} - initialValue={initialValue} - onContentChange={onContentChange} - queuedMessages={queuedMessages} - onDeleteQueuedMessage={onDeleteQueuedMessage} - onPromoteQueuedMessage={onPromoteQueuedMessage} - editingQueuedMessageID={editingQueuedMessageID} - onStartQueueEdit={onStartQueueEdit} - onCancelQueueEdit={onCancelQueueEdit} - isEditingHistoryMessage={isEditingHistoryMessage} - onCancelHistoryEdit={onCancelHistoryEdit} - isDisabled={isInputDisabled} - isLoading={isSendPending} - isStreaming={isStreaming} - onInterrupt={onInterrupt} - isInterruptPending={isInterruptPending} - contextUsage={latestContextUsage} - hasModelOptions={hasModelOptions} - selectedModel={selectedModel} - onModelChange={onModelChange} - modelOptions={modelOptions} - modelSelectorPlaceholder={modelSelectorPlaceholder} - inputStatusText={inputStatusText} - modelCatalogStatusMessage={modelCatalogStatusMessage} - /> - ); -}; /** @internal Exported for testing. */ export function useConversationEditingState(deps: { @@ -577,6 +245,7 @@ const AgentDetail: FC = () => { requestArchiveAgent, requestArchiveAndDeleteWorkspace, requestUnarchiveAgent, + onOpenAnalytics, isSidebarCollapsed, onToggleSidebarCollapsed, } = outletContext; @@ -849,6 +518,30 @@ const AgentDetail: FC = () => { interruptMutation.isPending; const isInputDisabled = !hasModelOptions || isArchived; + const handleUsageLimitError = useCallback( + (error: unknown): void => { + if (!agentId) { + return; + } + if ( + isApiError(error) && + error.response?.status === 409 && + isUsageLimitData(error.response.data) + ) { + setChatErrorReason(agentId, { + kind: "usage-limit", + message: formatUsageLimitMessage(error.response.data), + }); + } else if (isApiError(error)) { + setChatErrorReason(agentId, { + kind: "generic", + message: error.message || "An unexpected error occurred.", + }); + } + }, + [agentId, setChatErrorReason], + ); + const handleSend = async ( message: string, fileIds?: string[], @@ -914,6 +607,9 @@ const AgentDetail: FC = () => { messageId: editedMessageID, req: request, }); + } catch (error) { + handleUsageLimitError(error); + throw error; } finally { setPendingEditMessageId(null); } @@ -937,22 +633,27 @@ const AgentDetail: FC = () => { // timeline when the server confirms via the POST response or // via the SSE stream. store.clearStreamState(); - const response = await sendMutation.mutateAsync(request); - // When the server accepts the message immediately (not - // queued), insert it into the store so it appears in the - // timeline without waiting for the SSE stream. - if (!response.queued && response.message) { - store.upsertDurableMessage(response.message); - } - if (typeof window !== "undefined") { - if (selectedModelConfigID) { - localStorage.setItem( - lastModelConfigIDStorageKey, - selectedModelConfigID, - ); - } else { - localStorage.removeItem(lastModelConfigIDStorageKey); + try { + const response = await sendMutation.mutateAsync(request); + // When the server accepts the message immediately (not + // queued), insert it into the store so it appears in the + // timeline without waiting for the SSE stream. + if (!response.queued && response.message) { + store.upsertDurableMessage(response.message); } + if (typeof window !== "undefined") { + if (selectedModelConfigID) { + localStorage.setItem( + lastModelConfigIDStorageKey, + selectedModelConfigID, + ); + } else { + localStorage.removeItem(lastModelConfigIDStorageKey); + } + } + } catch (error) { + handleUsageLimitError(error); + throw error; } }; @@ -988,6 +689,9 @@ const AgentDetail: FC = () => { previousQueuedMessages.filter((message) => message.id !== id), ); store.clearStreamState(); + if (agentId) { + clearChatErrorReason(agentId); + } store.clearStreamError(); store.setChatStatus("pending"); try { @@ -995,10 +699,17 @@ const AgentDetail: FC = () => { } catch (error) { store.setQueuedMessages(previousQueuedMessages); store.setChatStatus(previousChatStatus); + handleUsageLimitError(error); throw error; } }, - [promoteQueuedMutation, store], + [ + agentId, + clearChatErrorReason, + handleUsageLimitError, + promoteQueuedMutation, + store, + ], ); const editing = useConversationEditingState({ @@ -1156,6 +867,7 @@ const AgentDetail: FC = () => { isInterruptPending={interruptMutation.isPending} isSidebarCollapsed={isSidebarCollapsed} onToggleSidebarCollapsed={onToggleSidebarCollapsed} + onOpenAnalytics={onOpenAnalytics} showSidebarPanel={showSidebarPanel} onSetShowSidebarPanel={handleSetShowSidebarPanel} prNumber={prNumber} diff --git a/site/src/pages/AgentsPage/AgentDetail/ChatContext.test.tsx b/site/src/pages/AgentsPage/AgentDetail/ChatContext.test.tsx index 95dd16277d..55fa6496cd 100644 --- a/site/src/pages/AgentsPage/AgentDetail/ChatContext.test.tsx +++ b/site/src/pages/AgentsPage/AgentDetail/ChatContext.test.tsx @@ -1578,10 +1578,10 @@ describe("useChatStore", () => { }); expect(result.current.streamError).toBe("Rate limit exceeded"); expect(result.current.retryState).toBeNull(); - expect(setChatErrorReason).toHaveBeenCalledWith( - chatID, - "Rate limit exceeded", - ); + expect(setChatErrorReason).toHaveBeenCalledWith(chatID, { + kind: "generic", + message: "Rate limit exceeded", + }); }); it("uses fallback message when error event has no message", async () => { diff --git a/site/src/pages/AgentsPage/AgentDetail/ChatContext.ts b/site/src/pages/AgentsPage/AgentDetail/ChatContext.ts index a61d44ec29..e03d5512c5 100644 --- a/site/src/pages/AgentsPage/AgentDetail/ChatContext.ts +++ b/site/src/pages/AgentsPage/AgentDetail/ChatContext.ts @@ -12,6 +12,7 @@ import { import { type InfiniteData, useQueryClient } from "react-query"; import type { OneWayMessageEvent } from "utils/OneWayWebSocket"; import { createReconnectingWebSocket } from "utils/reconnectingWebSocket"; +import type { ChatDetailError } from "../usageLimitMessage"; import { applyMessagePartToStreamState } from "./streamState"; import type { StreamState } from "./types"; @@ -419,7 +420,7 @@ interface UseChatStoreOptions { chatRecord: TypesGen.Chat | undefined; chatMessagesData: TypesGen.ChatMessagesResponse | undefined; chatQueuedMessages: readonly TypesGen.ChatQueuedMessage[] | undefined; - setChatErrorReason: (chatID: string, reason: string) => void; + setChatErrorReason: (chatID: string, reason: ChatDetailError) => void; clearChatErrorReason: (chatID: string) => void; } @@ -760,7 +761,10 @@ export const useChatStore = ( store.setChatStatus("error"); store.setStreamError(reason); store.clearRetryState(); - setChatErrorReason(chatID, reason); + setChatErrorReason(chatID, { + kind: "generic", + message: reason, + }); updateSidebarChat((chat) => ({ ...chat, status: "error", diff --git a/site/src/pages/AgentsPage/AgentDetail/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/AgentDetail/ConversationTimeline.stories.tsx index c59e3b2a99..dd787c5ad8 100644 --- a/site/src/pages/AgentsPage/AgentDetail/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/AgentDetail/ConversationTimeline.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import type * as TypesGen from "api/typesGenerated"; import { createRef } from "react"; -import { expect, within } from "storybook/test"; +import { expect, fn, userEvent, within } from "storybook/test"; import { ConversationTimeline } from "./ConversationTimeline"; import { buildParsedMessageSections, @@ -245,78 +245,47 @@ export const UserMessageWithImagesAndFileRefs: Story = { }, }; -/** File references render inline with text, matching the chat input style. */ -export const UserMessageWithInlineFileRef: Story = { +/** Usage-limit errors render as an info alert with analytics access. */ +export const UsageLimitExceeded: Story = { args: { ...defaultArgs, - parsedSections: buildSections([ - { - ...baseMessage, - id: 1, - role: "user", - content: [ - { type: "text", text: "Can you refactor " }, - { - type: "file-reference", - file_name: "site/src/components/Button.tsx", - start_line: 42, - end_line: 42, - content: "export const Button = ...", - }, - { type: "text", text: " to use the new API?" }, - ], - }, - { - ...baseMessage, - id: 2, - role: "assistant", - content: [{ type: "text", text: "Sure, I'll update that component." }], - }, - ]), + loadMoreSentinelRef: { current: null }, + parsedSections: [], + detailError: { + kind: "usage-limit", + message: + "You've used $50.00 of your $50.00 spend limit. Your limit resets on July 1, 2025.", + }, + onOpenAnalytics: fn(), + subagentTitles: new Map(), + subagentStatusOverrides: new Map(), }, - play: async ({ canvasElement }) => { + play: async ({ args, canvasElement }) => { const canvas = within(canvasElement); - // File ref chip is inline, rendered as a button - expect(canvas.getByText(/Button\.tsx/)).toBeInTheDocument(); - // Surrounding text is present - expect(canvas.getByText(/Can you refactor/)).toBeInTheDocument(); - expect(canvas.getByText(/to use the new API/)).toBeInTheDocument(); + expect(canvas.getByText(/spend limit/i)).toBeVisible(); + const btn = canvas.getByRole("button", { name: /view usage/i }); + expect(btn).toBeVisible(); + await userEvent.click(btn); + expect(args.onOpenAnalytics).toHaveBeenCalled(); }, }; -/** Multiple file references render inline, no separate section. */ -export const UserMessageWithMultipleInlineFileRefs: Story = { +/** Non-usage errors must not show the usage CTA. */ +export const GenericErrorDoesNotShowUsageAction: Story = { args: { ...defaultArgs, - parsedSections: buildSections([ - { - ...baseMessage, - id: 1, - role: "user", - content: [ - { type: "text", text: "Compare " }, - { - type: "file-reference", - file_name: "api/handler.go", - start_line: 1, - end_line: 50, - content: "...", - }, - { type: "text", text: " with " }, - { - type: "file-reference", - file_name: "api/handler_test.go", - start_line: 10, - end_line: 30, - content: "...", - }, - ], - }, - ]), + loadMoreSentinelRef: { current: null }, + parsedSections: [], + detailError: { kind: "generic", message: "Provider request failed." }, + onOpenAnalytics: fn(), + subagentTitles: new Map(), + subagentStatusOverrides: new Map(), }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect(canvas.getByText(/handler\.go/)).toBeInTheDocument(); - expect(canvas.getByText(/handler_test\.go/)).toBeInTheDocument(); + expect(canvas.getByText(/provider request failed/i)).toBeVisible(); + expect( + canvas.queryByRole("button", { name: /view usage/i }), + ).not.toBeInTheDocument(); }, }; diff --git a/site/src/pages/AgentsPage/AgentDetail/ConversationTimeline.tsx b/site/src/pages/AgentsPage/AgentDetail/ConversationTimeline.tsx index c9be9f9dcd..bb879f5e55 100644 --- a/site/src/pages/AgentsPage/AgentDetail/ConversationTimeline.tsx +++ b/site/src/pages/AgentsPage/AgentDetail/ConversationTimeline.tsx @@ -1,4 +1,5 @@ import type * as TypesGen from "api/typesGenerated"; +import { Alert } from "components/Alert/Alert"; import { ConversationItem, Message, @@ -8,12 +9,12 @@ import { Tool, } from "components/ai-elements"; import { WebSearchSources } from "components/ai-elements/tool"; -import { FileReferenceChip } from "components/ChatMessageInput/FileReferenceNode"; +import { Button } from "components/Button/Button"; +import { FileIcon } from "components/FileIcon/FileIcon"; import { Spinner } from "components/Spinner/Spinner"; import { ChevronDownIcon } from "lucide-react"; import { type FC, - Fragment, memo, type ReactNode, type RefObject, @@ -25,6 +26,7 @@ import type { UrlTransform } from "streamdown"; import { cn } from "utils/cn"; import { ImageThumbnail } from "../AgentChatInput"; import { ImageLightbox } from "../ImageLightbox"; +import type { ChatDetailError } from "../usageLimitMessage"; import { useSmoothStreamingText } from "./SmoothText"; import type { MergedTool, @@ -338,20 +340,6 @@ const ChatMessageItem = memo<{ parsed.blocks.length > 0 || parsed.tools.length > 0 || parsed.sources.length > 0; - - // Pre-compute the inline content for user messages so we - // avoid a filter + map inside the JSX return path. - const userInlineContent = isUser - ? parsed.blocks.filter( - ( - b, - ): b is - | Extract - | Extract => - b.type === "response" || b.type === "file-reference", - ) - : []; - const conversationItemProps: { role: "user" | "assistant" } = { role: isUser ? "user" : "assistant", }; @@ -407,20 +395,7 @@ const ChatMessageItem = memo<{
- {userInlineContent.length > 0 - ? userInlineContent.map((block, i) => - block.type === "response" ? ( - {block.text} - ) : ( - - ), - ) - : parsed.markdown || ""} + {parsed.markdown || ""} {isSavingMessage && ( ); })()} - {fadeFromBottom && ( + {(() => { + const fileRefBlocks = parsed.blocks.filter( + ( + b, + ): b is Extract< + RenderBlock, + { type: "file-reference" } + > => b.type === "file-reference", + ); + if (fileRefBlocks.length === 0) return null; + return ( +
+ {fileRefBlocks.map((dc, i) => ( +
+ + + {dc.fileName.split("/").pop()}: + {dc.startLine === dc.endLine + ? dc.startLine + : `${dc.startLine}\u2013${dc.endLine}`} + + {dc.text && ( + + {dc.text} + + )} +
+ ))} +
+ ); + })()} {fadeFromBottom && (
{/* Blur layer: extends 48px beyond the - clipped content so the frosted effect - is visible around the bubble. A mask - gradient fades the backdrop-blur out - gradually instead of a hard cutoff. - Promoted to its own GPU layer via - will-change. */} + clipped content so the frosted effect + is visible around the bubble. Promoted + to its own GPU layer via will-change. */}
- {/* Content layer: px-3 pt-1 sm:pt-2 matches - the sticky container's padding so the - overlay aligns with the flow element. - will-change promotes to GPU layer. */} + {/* Content layer: px-3 pt-2 matches the + sticky container's padding so the + overlay aligns with the flow element. + will-change promotes to GPU layer. */}
; retryState?: { attempt: number; error: string } | null; isAwaitingFirstStreamChunk: boolean; - detailErrorMessage?: string | null; + detailError?: ChatDetailError | null; + onOpenAnalytics?: () => void; onEditUserMessage?: ( messageId: number, text: string, @@ -884,7 +892,8 @@ export const ConversationTimeline: FC = ({ subagentStatusOverrides, retryState, isAwaitingFirstStreamChunk, - detailErrorMessage, + detailError, + onOpenAnalytics, onEditUserMessage, editingMessageId, savingMessageId, @@ -892,9 +901,11 @@ export const ConversationTimeline: FC = ({ }) => { const shouldRenderStreamInLastSection = hasStreamOutput && parsedSections.length > 0; + const isUsageLimitError = detailError?.kind === "usage-limit"; + const showUsageAction = onOpenAnalytics !== undefined && isUsageLimitError; return ( -
+
{isEmpty && !hasStreamOutput ? (

Start a conversation with your agent.

@@ -967,10 +978,20 @@ export const ConversationTimeline: FC = ({ )}
)} - {detailErrorMessage && ( -
- {detailErrorMessage} -
+ {detailError && ( + + View Usage + + ) + } + > + {detailError.message} + )}
); diff --git a/site/src/pages/AgentsPage/AgentDetailContent.tsx b/site/src/pages/AgentsPage/AgentDetailContent.tsx new file mode 100644 index 0000000000..69bd5b93e4 --- /dev/null +++ b/site/src/pages/AgentsPage/AgentDetailContent.tsx @@ -0,0 +1,347 @@ +import type * as TypesGen from "api/typesGenerated"; +import type { ModelSelectorOption } from "components/ai-elements"; +import { useDashboard } from "modules/dashboard/useDashboard"; +import { type FC, useEffect, useMemo } from "react"; +import { toast } from "sonner"; +import type { UrlTransform } from "streamdown"; +import { + AgentChatInput, + type ChatMessageInputRef, + type UploadState, +} from "./AgentChatInput"; +import { + selectChatStatus, + selectHasStreamState, + selectMessagesByID, + selectOrderedMessageIDs, + selectQueuedMessages, + selectRetryState, + selectStreamError, + selectStreamState, + selectSubagentStatusOverrides, + useChatSelector, + type useChatStore, +} from "./AgentDetail/ChatContext"; +import { ConversationTimeline } from "./AgentDetail/ConversationTimeline"; +import { getLatestContextUsage } from "./AgentDetail/chatHelpers"; +import { + buildParsedMessageSections, + buildSubagentTitles, + parseMessagesWithMergedTools, +} from "./AgentDetail/messageParsing"; +import { buildStreamTools } from "./AgentDetail/streamState"; +import { useMessageWindow } from "./AgentDetail/useMessageWindow"; +import type { ChatDetailError } from "./usageLimitMessage"; +import { useFileAttachments } from "./useFileAttachments"; + +type ChatStoreHandle = ReturnType["store"]; + +const isChatMessage = ( + message: TypesGen.ChatMessage | undefined, +): message is TypesGen.ChatMessage => Boolean(message); + +interface AgentDetailTimelineProps { + store: ChatStoreHandle; + chatID: string; + persistedErrorReason: ChatDetailError | undefined; + onOpenAnalytics?: () => void; + onEditUserMessage?: ( + messageId: number, + text: string, + fileBlocks?: readonly { mediaType: string; data?: string }[], + ) => void; + editingMessageId?: number | null; + savingMessageId?: number | null; + urlTransform?: UrlTransform; +} + +export const AgentDetailTimeline: FC = ({ + store, + chatID, + persistedErrorReason, + onOpenAnalytics, + onEditUserMessage, + editingMessageId, + savingMessageId, + urlTransform, +}) => { + const messagesByID = useChatSelector(store, selectMessagesByID); + const orderedMessageIDs = useChatSelector(store, selectOrderedMessageIDs); + const streamState = useChatSelector(store, selectStreamState); + const chatStatus = useChatSelector(store, selectChatStatus); + const streamError = useChatSelector(store, selectStreamError); + const subagentStatusOverrides = useChatSelector( + store, + selectSubagentStatusOverrides, + ); + const retryState = useChatSelector(store, selectRetryState); + + const messages = useMemo( + () => + orderedMessageIDs + .map((messageID) => messagesByID.get(messageID)) + .filter(isChatMessage), + [messagesByID, orderedMessageIDs], + ); + const streamTools = useMemo( + () => buildStreamTools(streamState), + [streamState], + ); + const { hasMoreMessages, windowedMessages, loadMoreSentinelRef } = + useMessageWindow({ + messages, + resetKey: chatID, + }); + const parsedMessages = useMemo( + () => parseMessagesWithMergedTools(windowedMessages), + [windowedMessages], + ); + const subagentTitles = useMemo( + () => buildSubagentTitles(parsedMessages), + [parsedMessages], + ); + const parsedSections = useMemo( + () => buildParsedMessageSections(parsedMessages), + [parsedMessages], + ); + const detailError: ChatDetailError | undefined = + (persistedErrorReason?.kind === "usage-limit" || chatStatus === "error" + ? persistedErrorReason + : undefined) ?? + (streamError + ? { kind: "generic" as const, message: streamError } + : undefined); + const latestMessage = messages[messages.length - 1]; + const latestMessageNeedsAssistantResponse = + !latestMessage || latestMessage.role !== "assistant"; + const isAwaitingFirstStreamChunk = + !streamState && + (chatStatus === "running" || chatStatus === "pending") && + latestMessageNeedsAssistantResponse; + const hasStreamOutput = Boolean(streamState) || isAwaitingFirstStreamChunk; + + return ( + + ); +}; + +interface AgentDetailInputProps { + store: ChatStoreHandle; + compressionThreshold: number | undefined; + onSend: (message: string, fileIds?: string[]) => void; + onDeleteQueuedMessage: (id: number) => Promise; + onPromoteQueuedMessage: (id: number) => Promise; + onInterrupt: () => void; + isInputDisabled: boolean; + isSendPending: boolean; + isInterruptPending: boolean; + hasModelOptions: boolean; + selectedModel: string; + onModelChange: (modelID: string) => void; + modelOptions: readonly ModelSelectorOption[]; + modelSelectorPlaceholder: string; + inputStatusText: string | null; + modelCatalogStatusMessage: string | null; + // Controlled input value and editing state, owned by the + // conversation component. + inputRef?: React.Ref; + initialValue?: string; + onContentChange?: (content: string) => void; + editingQueuedMessageID: number | null; + onStartQueueEdit: (id: number, text: string) => void; + onCancelQueueEdit: () => void; + isEditingHistoryMessage: boolean; + onCancelHistoryEdit: () => void; + // File blocks from the message being edited, converted to + // File objects and pre-populated into attachments. + editingFileBlocks?: readonly { + mediaType: string; + data?: string; + fileId?: string; + }[]; +} + +export const AgentDetailInput: FC = ({ + store, + compressionThreshold, + onSend, + onDeleteQueuedMessage, + onPromoteQueuedMessage, + onInterrupt, + isInputDisabled, + isSendPending, + isInterruptPending, + hasModelOptions, + selectedModel, + onModelChange, + modelOptions, + modelSelectorPlaceholder, + inputStatusText, + modelCatalogStatusMessage, + inputRef, + initialValue, + onContentChange, + editingQueuedMessageID, + onStartQueueEdit, + onCancelQueueEdit, + isEditingHistoryMessage, + onCancelHistoryEdit, + editingFileBlocks, +}) => { + const messagesByID = useChatSelector(store, selectMessagesByID); + const orderedMessageIDs = useChatSelector(store, selectOrderedMessageIDs); + const hasStreamState = useChatSelector(store, selectHasStreamState); + const chatStatus = useChatSelector(store, selectChatStatus); + const queuedMessages = useChatSelector(store, selectQueuedMessages); + + const messages = useMemo( + () => + orderedMessageIDs + .map((messageID) => messagesByID.get(messageID)) + .filter(isChatMessage), + [messagesByID, orderedMessageIDs], + ); + const { organizations } = useDashboard(); + const organizationId = organizations[0]?.id; + const latestContextUsage = useMemo(() => { + const usage = getLatestContextUsage(messages); + if (!usage) { + return usage; + } + return { ...usage, compressionThreshold }; + }, [messages, compressionThreshold]); + const { + attachments, + uploadStates, + previewUrls, + handleAttach, + handleRemoveAttachment, + resetAttachments, + setAttachments, + setPreviewUrls, + setUploadStates, + } = useFileAttachments(organizationId); + // Pre-populate attachments from existing file blocks when + // entering edit mode on a message with images. + useEffect(() => { + if (!editingFileBlocks || editingFileBlocks.length === 0) { + // Clear attachments when exiting edit mode. + setAttachments([]); + setUploadStates(new Map()); + setPreviewUrls(new Map()); + return; + } + const files = editingFileBlocks.map((block, i) => { + const ext = block.mediaType.split("/")[1] ?? "png"; + // Empty File used as a Map key only, its content is never + // read because the existing fileId is reused at send time. + return new File([], `attachment-${i}.${ext}`, { + type: block.mediaType, + }); + }); + setAttachments(files); + setPreviewUrls( + new Map( + files.map((f, i) => [ + f, + `/api/experimental/chats/files/${editingFileBlocks[i].fileId}`, + ]), + ), + ); + const newUploadStates = new Map(); + for (const [i, file] of files.entries()) { + const block = editingFileBlocks[i]; + if (block.fileId) { + newUploadStates.set(file, { + status: "uploaded", + fileId: block.fileId, + }); + } + } + setUploadStates(newUploadStates); + }, [editingFileBlocks, setAttachments, setPreviewUrls, setUploadStates]); + + const isStreaming = + hasStreamState || chatStatus === "running" || chatStatus === "pending"; + + return ( + { + void (async () => { + try { + // Collect file IDs from already-uploaded attachments. + // Skip files in error state (e.g. too large). + const fileIds: string[] = []; + let skippedErrors = 0; + for (const file of attachments) { + const state = uploadStates.get(file); + if (state?.status === "error") { + skippedErrors++; + continue; + } + if (state?.status === "uploaded" && state.fileId) { + fileIds.push(state.fileId); + } + } + if (skippedErrors > 0) { + toast.warning( + `${skippedErrors} attachment${skippedErrors > 1 ? "s" : ""} could not be sent (upload failed)`, + ); + } + await onSend(message, fileIds.length > 0 ? fileIds : undefined); + resetAttachments(); + } catch { + // Attachments preserved for retry on failure. + } + })(); + }} + attachments={attachments} + onAttach={handleAttach} + onRemoveAttachment={handleRemoveAttachment} + uploadStates={uploadStates} + previewUrls={previewUrls} + inputRef={inputRef} + initialValue={initialValue} + onContentChange={onContentChange} + queuedMessages={queuedMessages} + onDeleteQueuedMessage={onDeleteQueuedMessage} + onPromoteQueuedMessage={onPromoteQueuedMessage} + editingQueuedMessageID={editingQueuedMessageID} + onStartQueueEdit={onStartQueueEdit} + onCancelQueueEdit={onCancelQueueEdit} + isEditingHistoryMessage={isEditingHistoryMessage} + onCancelHistoryEdit={onCancelHistoryEdit} + isDisabled={isInputDisabled} + isLoading={isSendPending} + isStreaming={isStreaming} + onInterrupt={onInterrupt} + isInterruptPending={isInterruptPending} + contextUsage={latestContextUsage} + hasModelOptions={hasModelOptions} + selectedModel={selectedModel} + onModelChange={onModelChange} + modelOptions={modelOptions} + modelSelectorPlaceholder={modelSelectorPlaceholder} + inputStatusText={inputStatusText} + modelCatalogStatusMessage={modelCatalogStatusMessage} + /> + ); +}; diff --git a/site/src/pages/AgentsPage/AgentDetailView.stories.tsx b/site/src/pages/AgentsPage/AgentDetailView.stories.tsx index eedf699b81..771a25a2be 100644 --- a/site/src/pages/AgentsPage/AgentDetailView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentDetailView.stories.tsx @@ -174,7 +174,9 @@ export const WithParentChat: Story = { /** Persisted error reason shown in the timeline area. */ export const WithError: Story = { args: { - chatErrorReasons: { [AGENT_ID]: "Model rate limited" }, + chatErrorReasons: { + [AGENT_ID]: { kind: "generic", message: "Model rate limited" }, + }, }, }; diff --git a/site/src/pages/AgentsPage/AgentDetailView.tsx b/site/src/pages/AgentsPage/AgentDetailView.tsx index d815803ead..6d30c5884f 100644 --- a/site/src/pages/AgentsPage/AgentDetailView.tsx +++ b/site/src/pages/AgentsPage/AgentDetailView.tsx @@ -7,9 +7,13 @@ import type { UrlTransform } from "streamdown"; import { cn } from "utils/cn"; import { pageTitle } from "utils/page"; import { AgentChatInput, type ChatMessageInputRef } from "./AgentChatInput"; -import { AgentDetailInput, AgentDetailTimeline } from "./AgentDetail"; -import type { useChatStore } from "./AgentDetail/ChatContext"; +import { + selectChatStatus, + useChatSelector, + type useChatStore, +} from "./AgentDetail/ChatContext"; import { AgentDetailTopBar } from "./AgentDetail/TopBar"; +import { AgentDetailInput, AgentDetailTimeline } from "./AgentDetailContent"; import { ChatConversationSkeleton, RightPanelSkeleton, @@ -17,6 +21,7 @@ import { import { GitPanel } from "./GitPanel"; import { RightPanel } from "./RightPanel"; import { SidebarTabView } from "./SidebarTabView"; +import type { ChatDetailError } from "./usageLimitMessage"; type ChatStoreHandle = ReturnType["store"]; @@ -53,7 +58,7 @@ interface AgentDetailViewProps { agentId: string; chatTitle: string | undefined; parentChat: TypesGen.Chat | undefined; - chatErrorReasons: Record; + chatErrorReasons: Record; chatRecord: TypesGen.Chat | undefined; isArchived: boolean; hasWorkspace: boolean; @@ -81,6 +86,7 @@ interface AgentDetailViewProps { // Sidebar / panel state. isSidebarCollapsed: boolean; onToggleSidebarCollapsed: () => void; + onOpenAnalytics?: () => void; // Right panel state (owned by the parent so loading and // loaded views share the same layout). @@ -155,6 +161,7 @@ export const AgentDetailView: FC = ({ isInterruptPending, isSidebarCollapsed, onToggleSidebarCollapsed, + onOpenAnalytics, showSidebarPanel, onSetShowSidebarPanel, prNumber, @@ -186,6 +193,7 @@ export const AgentDetailView: FC = ({ null, ); const visualExpanded = dragVisualExpanded ?? isRightPanelExpanded; + const chatStatus = useChatSelector(store, selectChatStatus); // Compute local diff stats from git watcher unified diffs. @@ -263,8 +271,12 @@ export const AgentDetailView: FC = ({ store={store} chatID={agentId} persistedErrorReason={ - chatErrorReasons[agentId] || chatRecord?.last_error || undefined + chatErrorReasons[agentId] ?? + (chatStatus === "error" && chatRecord?.last_error + ? { kind: "generic" as const, message: chatRecord.last_error } + : undefined) } + onOpenAnalytics={onOpenAnalytics} onEditUserMessage={editing.handleEditUserMessage} editingMessageId={editing.editingMessageId} savingMessageId={pendingEditMessageId} diff --git a/site/src/pages/AgentsPage/AgentEmbedPage.tsx b/site/src/pages/AgentsPage/AgentEmbedPage.tsx index 5c65c864e6..2d44675938 100644 --- a/site/src/pages/AgentsPage/AgentEmbedPage.tsx +++ b/site/src/pages/AgentsPage/AgentEmbedPage.tsx @@ -17,6 +17,7 @@ import { useMutation, useQueryClient } from "react-query"; import { Outlet, useParams } from "react-router"; import type { AgentsOutletContext } from "./AgentsPage"; import { bootstrapChatEmbedSession, EmbedProvider } from "./EmbedContext"; +import type { ChatDetailError } from "./usageLimitMessage"; type BootstrapMessage = { type: "coder:vscode-auth-bootstrap"; @@ -64,25 +65,33 @@ const AgentEmbedPage: FC = () => { const inFlightBootstrapRef = useRef | null>(null); const [chatErrorReasons, setChatErrorReasons] = useState< - Record + Record >({}); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); - const setChatErrorReason = useCallback((chatId: string, reason: string) => { - const trimmedReason = reason.trim(); - if (!chatId || !trimmedReason) { - return; - } - setChatErrorReasons((current) => { - if (current[chatId] === trimmedReason) { - return current; + const setChatErrorReason = useCallback( + (chatId: string, reason: ChatDetailError) => { + const trimmedMessage = reason.message.trim(); + if (!chatId || !trimmedMessage) { + return; } - return { - ...current, - [chatId]: trimmedReason, - }; - }); - }, []); + setChatErrorReasons((current) => { + const existing = current[chatId]; + if ( + existing && + existing.kind === reason.kind && + existing.message === trimmedMessage + ) { + return current; + } + return { + ...current, + [chatId]: { kind: reason.kind, message: trimmedMessage }, + }; + }); + }, + [], + ); const clearChatErrorReason = useCallback((chatId: string) => { if (!chatId) { diff --git a/site/src/pages/AgentsPage/AgentsPage.tsx b/site/src/pages/AgentsPage/AgentsPage.tsx index b8d88849eb..55f743ef76 100644 --- a/site/src/pages/AgentsPage/AgentsPage.tsx +++ b/site/src/pages/AgentsPage/AgentsPage.tsx @@ -42,6 +42,7 @@ import { maybePlayChime } from "./AgentDetail/useAgentChime"; import type { AgentsOutletContext } from "./AgentsPageView"; import { AgentsPageView } from "./AgentsPageView"; import { getModelOptionsFromCatalog } from "./modelOptions"; +import type { ChatDetailError } from "./usageLimitMessage"; import { useAgentsPageKeybindings } from "./useAgentsPageKeybindings"; import { useAgentsPWA } from "./useAgentsPWA"; @@ -173,7 +174,7 @@ const AgentsPage: FC = () => { }); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); const [chatErrorReasons, setChatErrorReasons] = useState< - Record + Record >({}); const catalogModelOptions = useMemo( () => @@ -202,21 +203,29 @@ const AgentsPage: FC = () => { } return byModelID; }, [chatModelConfigsQuery.data]); - const setChatErrorReason = useCallback((chatId: string, reason: string) => { - const trimmedReason = reason.trim(); - if (!chatId || !trimmedReason) { - return; - } - setChatErrorReasons((current) => { - if (current[chatId] === trimmedReason) { - return current; + const setChatErrorReason = useCallback( + (chatId: string, reason: ChatDetailError) => { + const trimmedMessage = reason.message.trim(); + if (!chatId || !trimmedMessage) { + return; } - return { - ...current, - [chatId]: trimmedReason, - }; - }); - }, []); + setChatErrorReasons((current) => { + const existing = current[chatId]; + if ( + existing && + existing.kind === reason.kind && + existing.message === trimmedMessage + ) { + return current; + } + return { + ...current, + [chatId]: { kind: reason.kind, message: trimmedMessage }, + }; + }); + }, + [], + ); const clearChatErrorReason = useCallback((chatId: string) => { if (!chatId) { return; @@ -393,6 +402,7 @@ const AgentsPage: FC = () => { queryClient.invalidateQueries({ queryKey: chatDiffContentsKey(updatedChat.id), }), + invalidateChatListQueries(queryClient), ]); } // Scope field updates by event kind so that @@ -413,8 +423,10 @@ const AgentsPage: FC = () => { prependToInfiniteChatsCache(queryClient, updatedChat); } else { updateInfiniteChatsCache(queryClient, (chats) => { - return chats.map((c) => { + let didUpdate = false; + const nextChats = chats.map((c) => { if (c.id !== updatedChat.id) return c; + didUpdate = true; return { ...c, ...(isStatusEvent && { status: updatedChat.status }), @@ -428,6 +440,7 @@ const AgentsPage: FC = () => { : updatedChat.updated_at, }; }); + return didUpdate ? nextChats : chats; }); } queryClient.setQueryData( diff --git a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx index 5d9baa3c45..50b5a05e92 100644 --- a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx @@ -368,8 +368,8 @@ export const WithErrorReasons: Story = { ], outletContext: { chatErrorReasons: { - "chat-1": "Model rate limited", - "chat-3": "Context window exceeded", + "chat-1": { kind: "generic", message: "Model rate limited" }, + "chat-3": { kind: "generic", message: "Context window exceeded" }, }, setChatErrorReason: fn(), clearChatErrorReason: fn(), diff --git a/site/src/pages/AgentsPage/AgentsPageView.tsx b/site/src/pages/AgentsPage/AgentsPageView.tsx index 315dc4815b..4b3a6defad 100644 --- a/site/src/pages/AgentsPage/AgentsPageView.tsx +++ b/site/src/pages/AgentsPage/AgentsPageView.tsx @@ -4,7 +4,7 @@ import { Button } from "components/Button/Button"; import { ExternalImage } from "components/ExternalImage/ExternalImage"; import { CoderIcon } from "components/Icons/CoderIcon"; import { PanelLeftIcon } from "lucide-react"; -import { type FC, useState } from "react"; +import { type FC, useCallback, useMemo, useState } from "react"; import { NavLink, Outlet } from "react-router"; import { cn } from "utils/cn"; import { pageTitle } from "utils/page"; @@ -16,13 +16,14 @@ import { type ConfigureAgentsSection, } from "./ConfigureAgentsDialog"; import { UserAnalyticsDialog } from "./UserAnalyticsDialog"; +import type { ChatDetailError } from "./usageLimitMessage"; import { WebPushButton } from "./WebPushButton"; type ChatModelOption = ModelSelectorOption; export interface AgentsOutletContext { - chatErrorReasons: Record; - setChatErrorReason: (chatId: string, reason: string) => void; + chatErrorReasons: Record; + setChatErrorReason: (chatId: string, reason: ChatDetailError) => void; clearChatErrorReason: (chatId: string) => void; requestArchiveAgent: (chatId: string) => void; requestUnarchiveAgent: (chatId: string) => void; @@ -30,6 +31,7 @@ export interface AgentsOutletContext { chatId: string, workspaceId: string, ) => void; + onOpenAnalytics?: () => void; isSidebarCollapsed: boolean; onToggleSidebarCollapsed: () => void; } @@ -109,6 +111,24 @@ export const AgentsPageView: FC = ({ const [isUserAnalyticsDialogOpen, setUserAnalyticsDialogOpen] = useState(false); const [analyticsDialogKey, setAnalyticsDialogKey] = useState(0); + const handleOpenAnalytics = useCallback(() => { + setAnalyticsDialogKey((key) => key + 1); + setUserAnalyticsDialogOpen(true); + }, []); + const sidebarChatErrorReasons = useMemo( + () => + Object.fromEntries( + Object.entries(chatErrorReasons).map(([chatId, error]) => [ + chatId, + error.message, + ]), + ), + [chatErrorReasons], + ); + const outletContextValue = useMemo( + () => ({ ...outletContext, onOpenAnalytics: handleOpenAnalytics }), + [outletContext, handleOpenAnalytics], + ); return (
{pageTitle("Agents")} @@ -123,7 +143,7 @@ export const AgentsPageView: FC = ({ > = ({ archivedFilter={archivedFilter} onArchivedFilterChange={onArchivedFilterChange} onCollapse={onCollapseSidebar} - onOpenAnalytics={() => { - setAnalyticsDialogKey((key) => key + 1); - setUserAnalyticsDialogOpen(true); - }} + onOpenAnalytics={handleOpenAnalytics} onOpenSettings={() => { setConfigureAgentsInitialSection("behavior"); setConfigDialogKey((key) => key + 1); @@ -162,7 +179,7 @@ export const AgentsPageView: FC = ({ )} > {agentId ? ( - + ) : ( <>
@@ -203,6 +220,7 @@ export const AgentsPageView: FC = ({ isModelCatalogLoading={isModelCatalogLoading} isModelConfigsLoading={isModelConfigsLoading} modelCatalogError={modelCatalogError} + onOpenAnalytics={handleOpenAnalytics} /> )} diff --git a/site/src/pages/AgentsPage/ChatCostSummaryView.tsx b/site/src/pages/AgentsPage/ChatCostSummaryView.tsx index 9f6ceb75fa..496cf3335e 100644 --- a/site/src/pages/AgentsPage/ChatCostSummaryView.tsx +++ b/site/src/pages/AgentsPage/ChatCostSummaryView.tsx @@ -10,9 +10,11 @@ import { TableHeader, TableRow, } from "components/Table/Table"; +import dayjs from "dayjs"; import { TriangleAlertIcon } from "lucide-react"; import type { FC } from "react"; -import { formatCostMicros, formatTokenCount } from "utils/analytics"; +import { formatTokenCount } from "utils/analytics"; +import { formatCostMicros } from "utils/currency"; interface ChatCostSummaryViewProps { summary: TypesGen.ChatCostSummary | undefined; @@ -23,6 +25,25 @@ interface ChatCostSummaryViewProps { emptyMessage: string; } +const getUsageLimitPeriodLabel = ( + period: TypesGen.ChatUsageLimitPeriod | undefined, +): string => { + if (!period) { + return ""; + } + + switch (period) { + case "day": + return "Daily"; + case "week": + return "Weekly"; + case "month": + return "Monthly"; + default: + return ""; + } +}; + export const ChatCostSummaryView: FC = ({ summary, isLoading, @@ -60,6 +81,41 @@ export const ChatCostSummaryView: FC = ({ return null; } + const usageLimit = summary.usage_limit; + const showUsageLimitCard = usageLimit?.is_limited === true; + const usageLimitCurrentSpend = usageLimit?.current_spend ?? 0; + const usageLimitSpendMicros = usageLimit?.spend_limit_micros ?? 0; + const usageLimitPeriodLabel = usageLimit + ? getUsageLimitPeriodLabel(usageLimit.period) + : ""; + const usageProgressPercentage = + showUsageLimitCard && usageLimitSpendMicros > 0 + ? Math.min((usageLimitCurrentSpend / usageLimitSpendMicros) * 100, 100) + : 0; + const usageProgressBarClass = + usageProgressPercentage > 90 + ? "bg-surface-red" + : usageProgressPercentage >= 75 + ? "bg-surface-orange" + : "bg-surface-green"; + const usageLimitExceeded = + showUsageLimitCard && usageLimitCurrentSpend >= usageLimitSpendMicros; + const usageLimitStatusText = usageLimitExceeded + ? "Limit exceeded" + : `${formatCostMicros( + Math.max(usageLimitSpendMicros - usageLimitCurrentSpend, 0), + )} remaining`; + const usageLimitCurrentPeriod = + showUsageLimitCard && usageLimit?.period_start && usageLimit?.period_end + ? `Current period: ${dayjs(usageLimit.period_start).format("MMM D")} – ${dayjs( + usageLimit.period_end, + ).format("MMM D")}` + : ""; + const usageLimitResetAt = + showUsageLimitCard && usageLimit?.period_end + ? dayjs(usageLimit.period_end).format("MMM D, YYYY h:mm A") + : ""; + return (
@@ -115,6 +171,59 @@ export const ChatCostSummaryView: FC = ({
+ {showUsageLimitCard && usageLimit && ( +
+
+
+
+

+ {usageLimitPeriodLabel} Spend Limit +

+ {usageLimitCurrentPeriod && ( +

+ {usageLimitCurrentPeriod} +

+ )} +

+ {formatCostMicros(usageLimitCurrentSpend)} /{" "} + {formatCostMicros(usageLimitSpendMicros)} +

+
+

+ {Math.round(usageProgressPercentage)}% used +

+
+
+
+
+
+

+ {usageLimitStatusText} +

+

+ Resets {usageLimitResetAt} +

+
+
+
+ )} + {summary.unpriced_message_count > 0 && (
diff --git a/site/src/pages/AgentsPage/ConfigureAgentsDialog.stories.tsx b/site/src/pages/AgentsPage/ConfigureAgentsDialog.stories.tsx index f861b3504a..eba2fde6af 100644 --- a/site/src/pages/AgentsPage/ConfigureAgentsDialog.stories.tsx +++ b/site/src/pages/AgentsPage/ConfigureAgentsDialog.stories.tsx @@ -4,7 +4,9 @@ import { chatModelConfigsKey, chatModelsKey, chatProviderConfigsKey, + chatUsageLimitConfigKey, } from "api/queries/chats"; +import { groupsQueryKey } from "api/queries/groups"; import type { ChatCostSummary, ChatCostUserRollup, @@ -12,6 +14,10 @@ import type { ChatModelConfig, ChatModelsResponse, ChatProviderConfig, + ChatUsageLimitConfigResponse, + ChatUsageLimitGroupOverride, + ChatUsageLimitOverride, + Group, } from "api/typesGenerated"; import { expect, @@ -155,6 +161,65 @@ const mockUsageSummary: ChatCostSummary = { ], }; +const mockGroupOverrides: ChatUsageLimitGroupOverride[] = [ + { + group_id: "grp-1", + group_name: "engineering", + group_display_name: "Engineering", + group_avatar_url: "", + member_count: 12, + spend_limit_micros: 20_000_000, + }, +]; + +const mockUserOverrides: ChatUsageLimitOverride[] = [ + { + user_id: "user-1", + username: "alice", + name: "Alice Example", + avatar_url: "https://example.com/alice.png", + spend_limit_micros: 50_000_000, + }, +]; + +const mockLimitConfig: ChatUsageLimitConfigResponse = { + spend_limit_micros: 10_000_000, + period: "month", + updated_at: "2026-03-01T00:00:00Z", + unpriced_model_count: 1, + group_overrides: mockGroupOverrides, + overrides: mockUserOverrides, +}; + +const mockGroups: Group[] = [ + { + id: "grp-1", + name: "engineering", + display_name: "Engineering", + organization_id: "org-1", + members: [], + total_member_count: 12, + avatar_url: "", + quota_allowance: 0, + source: "user", + organization_name: "default", + organization_display_name: "Default", + }, + { + id: "grp-2", + name: "design", + display_name: "Design", + organization_id: "org-1", + members: [], + total_member_count: 5, + avatar_url: "", + quota_allowance: 0, + source: "user", + organization_name: "default", + organization_display_name: "Default", + }, +]; + const meta: Meta = { title: "pages/AgentsPage/ConfigureAgentsDialog", component: ConfigureAgentsDialog, @@ -251,3 +316,29 @@ export const UsageTab: Story = { spyOn(API, "getChatCostSummary").mockResolvedValue(mockUsageSummary); }, }; + +/** Admin sees the Limits tab with global, group, and user override data. */ +export const LimitsTab: Story = { + args: { + initialSection: "limits", + canManageChatModelConfigs: true, + }, + parameters: { + queries: [ + ...chatQueries, + { key: chatUsageLimitConfigKey, data: mockLimitConfig }, + { key: groupsQueryKey, data: mockGroups }, + ], + }, + beforeEach: () => { + spyOn(API, "updateChatUsageLimitConfig").mockResolvedValue(mockLimitConfig); + spyOn(API, "upsertChatUsageLimitGroupOverride").mockResolvedValue( + mockGroupOverrides[0]!, + ); + spyOn(API, "deleteChatUsageLimitGroupOverride").mockResolvedValue(); + spyOn(API, "upsertChatUsageLimitOverride").mockResolvedValue( + mockUserOverrides[0]!, + ); + spyOn(API, "deleteChatUsageLimitOverride").mockResolvedValue(); + }, +}; diff --git a/site/src/pages/AgentsPage/ConfigureAgentsDialog.tsx b/site/src/pages/AgentsPage/ConfigureAgentsDialog.tsx index 66d59bfc97..c008373578 100644 --- a/site/src/pages/AgentsPage/ConfigureAgentsDialog.tsx +++ b/site/src/pages/AgentsPage/ConfigureAgentsDialog.tsx @@ -44,6 +44,7 @@ import { BarChart3Icon, BoxesIcon, KeyRoundIcon, + ShieldAlertIcon, ShieldIcon, UserIcon, XIcon, @@ -56,15 +57,18 @@ import { useQueryClient, } from "react-query"; import TextareaAutosize from "react-textarea-autosize"; -import { formatCostMicros, formatTokenCount } from "utils/analytics"; +import { formatTokenCount } from "utils/analytics"; import { cn } from "utils/cn"; +import { formatCostMicros } from "utils/currency"; import { ChatCostSummaryView } from "./ChatCostSummaryView"; import { ChatModelAdminPanel } from "./ChatModelAdminPanel/ChatModelAdminPanel"; +import { LimitsTab } from "./LimitsTab"; import { SectionHeader } from "./SectionHeader"; export type ConfigureAgentsSection = | "providers" | "models" + | "limits" | "behavior" | "usage"; @@ -444,6 +448,12 @@ export const ConfigureAgentsDialog: FC = ({ icon: BoxesIcon, adminOnly: true, }); + options.push({ + id: "limits", + label: "Limits", + icon: ShieldAlertIcon, + adminOnly: true, + }); options.push({ id: "usage", label: "Usage", @@ -520,9 +530,9 @@ export const ConfigureAgentsDialog: FC = ({ })} -
+
{activeSection === "behavior" && ( - <> +
= ({ )} - +
)} {activeSection === "providers" && canManageChatModelConfigs && ( - } - /> +
+ } + /> +
)} {activeSection === "models" && canManageChatModelConfigs && ( - } - /> +
+ } + /> +
+ )} + {activeSection === "limits" && canManageChatModelConfigs && ( + )} {activeSection === "usage" && canManageChatModelConfigs && ( - +
+ +
)}
diff --git a/site/src/pages/AgentsPage/LimitsTab/DefaultLimitSection.tsx b/site/src/pages/AgentsPage/LimitsTab/DefaultLimitSection.tsx new file mode 100644 index 0000000000..e2fd4a5396 --- /dev/null +++ b/site/src/pages/AgentsPage/LimitsTab/DefaultLimitSection.tsx @@ -0,0 +1,139 @@ +import type { ChatUsageLimitPeriod } from "api/typesGenerated"; +import { Input } from "components/Input/Input"; +import { Label } from "components/Label/Label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "components/Select/Select"; +import { Switch } from "components/Switch/Switch"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "components/Tooltip/Tooltip"; +import { InfoIcon, TriangleAlertIcon } from "lucide-react"; +import { type FC, type ReactNode, useId } from "react"; +import { SectionHeader } from "../SectionHeader"; + +interface DefaultLimitSectionProps { + enabled: boolean; + onEnabledChange: (enabled: boolean) => void; + period: ChatUsageLimitPeriod; + onPeriodChange: (period: ChatUsageLimitPeriod) => void; + amountDollars: string; + onAmountDollarsChange: (amount: string) => void; + unpricedModelCount: number; + adminBadge: ReactNode; + panelClassName: string; +} + +export const DefaultLimitSection: FC = ({ + enabled, + onEnabledChange, + period, + onPeriodChange, + amountDollars, + onAmountDollarsChange, + unpricedModelCount, + adminBadge, + panelClassName, +}) => { + const periodId = useId(); + const amountId = useId(); + + return ( + <> + + +
+
+
+

+ Enable spend limit +

+

+ When disabled, users have unlimited spending. +

+
+ +
+ + {enabled && ( +
+
+
+ + + + + + + + Only one period can be active at a time. Spend is + calculated from the start of the current period. + + + +
+ +
+
+ + onAmountDollarsChange(event.target.value)} + placeholder="0.00" + /> +
+
+ )} +
+ + {enabled && unpricedModelCount > 0 && ( +
+ +
+ {unpricedModelCount === 1 + ? "1 enabled model does not have pricing configured." + : `${unpricedModelCount} enabled models do not have pricing configured.`}{" "} + Usage of unpriced models cannot be tracked against the spend limit. +
+
+ )} + + ); +}; diff --git a/site/src/pages/AgentsPage/LimitsTab/GroupLimitsSection.tsx b/site/src/pages/AgentsPage/LimitsTab/GroupLimitsSection.tsx new file mode 100644 index 0000000000..619c37543f --- /dev/null +++ b/site/src/pages/AgentsPage/LimitsTab/GroupLimitsSection.tsx @@ -0,0 +1,239 @@ +import { getErrorMessage } from "api/errors"; +import type { Group } from "api/typesGenerated"; +import { Autocomplete } from "components/Autocomplete/Autocomplete"; +import { AvatarData } from "components/Avatar/AvatarData"; +import { Button } from "components/Button/Button"; +import { Input } from "components/Input/Input"; +import { Label } from "components/Label/Label"; +import { Spinner } from "components/Spinner/Spinner"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "components/Table/Table"; +import { Check } from "lucide-react"; +import { getGroupSubtitle } from "modules/groups"; +import { type FC, useId } from "react"; +import { formatCostMicros, isPositiveFiniteDollarAmount } from "utils/currency"; +import { SectionHeader } from "../SectionHeader"; + +interface GroupLimitsSectionProps { + groupOverrides: ReadonlyArray<{ + group_id: string; + group_display_name: string; + group_name: string; + group_avatar_url: string; + member_count: number; + spend_limit_micros: number | null; + }>; + panelClassName: string; + showGroupForm: boolean; + onShowGroupFormChange: (show: boolean) => void; + selectedGroup: Group | null; + onSelectedGroupChange: (group: Group | null) => void; + groupAmount: string; + onGroupAmountChange: (amount: string) => void; + availableGroups: Group[]; + groupAutocompleteNoOptionsText: string; + groupsLoading: boolean; + onAddGroupOverride: () => void; + onDeleteGroupOverride: (groupID: string) => void; + upsertPending: boolean; + upsertError: Error | null; + deletePending: boolean; + deleteError: Error | null; + groupsError: Error | null; +} + +export const GroupLimitsSection: FC = ({ + groupOverrides, + panelClassName, + showGroupForm, + onShowGroupFormChange, + selectedGroup, + onSelectedGroupChange, + groupAmount, + onGroupAmountChange, + availableGroups, + groupAutocompleteNoOptionsText, + groupsLoading, + onAddGroupOverride, + onDeleteGroupOverride, + upsertPending, + upsertError, + deletePending, + deleteError, + groupsError, +}) => { + const groupAutocompleteId = useId(); + const groupAmountId = useId(); + + return ( +
+ + +
+ {groupOverrides.length > 0 ? ( + + + + Group + Members + Spend Limit + Actions + + + + {groupOverrides.map((override) => ( + + + + + {override.member_count} + + {override.spend_limit_micros !== null + ? formatCostMicros(override.spend_limit_micros) + : "Unlimited"} + + + + + + ))} + +
+ ) : ( +
+ No group overrides configured. +
+ )} + + {deleteError && ( +

+ {getErrorMessage(deleteError, "Failed to delete group override.")} +

+ )} + + {!showGroupForm ? ( + + ) : ( +
+
+
+ + group.id} + getOptionLabel={(group) => group.display_name || group.name} + isOptionEqualToValue={(option, optionValue) => + option.id === optionValue.id + } + renderOption={(option, isSelected) => ( +
+ + {isSelected && } +
+ )} + placeholder="Search groups..." + noOptionsText={groupAutocompleteNoOptionsText} + loading={groupsLoading} + disabled={groupsLoading} + className="w-full" + /> +
+
+ + onGroupAmountChange(event.target.value)} + placeholder="0.00" + /> +
+
+ + +
+
+
+ )} + {upsertError && ( +

+ {getErrorMessage(upsertError, "Failed to save group override.")} +

+ )} + {groupsError && ( +

+ {getErrorMessage(groupsError, "Failed to load groups.")} +

+ )} +
+
+ ); +}; diff --git a/site/src/pages/AgentsPage/LimitsTab/LimitsTab.tsx b/site/src/pages/AgentsPage/LimitsTab/LimitsTab.tsx new file mode 100644 index 0000000000..d1c3022124 --- /dev/null +++ b/site/src/pages/AgentsPage/LimitsTab/LimitsTab.tsx @@ -0,0 +1,421 @@ +import { getErrorMessage } from "api/errors"; +import { + chatUsageLimitConfig, + deleteChatUsageLimitGroupOverride, + deleteChatUsageLimitOverride, + updateChatUsageLimitConfig, + upsertChatUsageLimitGroupOverride, + upsertChatUsageLimitOverride, +} from "api/queries/chats"; +import { groups } from "api/queries/groups"; +import type { ChatUsageLimitPeriod, Group, User } from "api/typesGenerated"; +import { Button } from "components/Button/Button"; +import { Spinner } from "components/Spinner/Spinner"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "components/Tooltip/Tooltip"; +import { ShieldIcon } from "lucide-react"; +import { type FC, type ReactNode, useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "react-query"; +import { + dollarsToMicros, + isPositiveFiniteDollarAmount, + microsToDollars, +} from "utils/currency"; +import { DefaultLimitSection } from "./DefaultLimitSection"; +import { GroupLimitsSection } from "./GroupLimitsSection"; +import { normalizeChatUsageLimitPeriod } from "./limitsFormLogic"; +import { UserOverridesSection } from "./UserOverridesSection"; + +const sectionPanelClassName = "space-y-4 rounded-lg border border-border p-4"; + +interface DefaultLimitFormValues { + enabled: boolean; + period: ChatUsageLimitPeriod; + amountDollars: string; +} + +interface DefaultLimitControllerProps { + initialValues: DefaultLimitFormValues; + onSave: (values: DefaultLimitFormValues) => Promise; + children: (props: { + enabled: boolean; + onEnabledChange: (enabled: boolean) => void; + period: ChatUsageLimitPeriod; + onPeriodChange: (period: ChatUsageLimitPeriod) => void; + amountDollars: string; + onAmountDollarsChange: (amount: string) => void; + isAmountValid: boolean; + saveDefault: () => Promise; + }) => ReactNode; +} + +const AdminBadge: FC = () => ( + + + + + + Admin + + + + Only visible to deployment administrators. + + + +); + +const DefaultLimitController: FC = ({ + initialValues, + onSave, + children, +}) => { + const [enabled, setEnabled] = useState(initialValues.enabled); + const [period, setPeriod] = useState( + initialValues.period, + ); + const [amountDollars, setAmountDollars] = useState( + initialValues.amountDollars, + ); + const isAmountValid = !enabled || isPositiveFiniteDollarAmount(amountDollars); + + const handleSave = async () => { + if (enabled && !isPositiveFiniteDollarAmount(amountDollars)) { + return; + } + + await onSave({ enabled, period, amountDollars }); + }; + + return children({ + enabled, + onEnabledChange: setEnabled, + period, + onPeriodChange: setPeriod, + amountDollars, + onAmountDollarsChange: setAmountDollars, + isAmountValid, + saveDefault: handleSave, + }); +}; + +export const LimitsTab: FC = () => { + const queryClient = useQueryClient(); + const configQuery = useQuery(chatUsageLimitConfig()); + const updateConfigMutation = useMutation( + updateChatUsageLimitConfig(queryClient), + ); + const upsertOverrideMutation = useMutation( + upsertChatUsageLimitOverride(queryClient), + ); + const deleteOverrideMutation = useMutation( + deleteChatUsageLimitOverride(queryClient), + ); + const groupsQuery = useQuery(groups()); + const upsertGroupOverrideMutation = useMutation( + upsertChatUsageLimitGroupOverride(queryClient), + ); + const deleteGroupOverrideMutation = useMutation( + deleteChatUsageLimitGroupOverride(queryClient), + ); + + const [showGroupForm, setShowGroupForm] = useState(false); + const [selectedGroup, setSelectedGroup] = useState(null); + const [groupAmount, setGroupAmount] = useState(""); + const [showUserForm, setShowUserForm] = useState(false); + const [selectedUser, setSelectedUser] = useState(null); + const [userOverrideAmount, setUserOverrideAmount] = useState(""); + + const defaultLimitValues = useMemo(() => { + const spendLimitMicros = configQuery.data?.spend_limit_micros; + const enabled = spendLimitMicros !== null && spendLimitMicros !== undefined; + + return { + enabled, + period: normalizeChatUsageLimitPeriod(configQuery.data?.period), + amountDollars: + enabled && spendLimitMicros !== null && spendLimitMicros !== undefined + ? microsToDollars(spendLimitMicros).toString() + : "", + }; + }, [configQuery.data?.period, configQuery.data?.spend_limit_micros]); + const defaultLimitKey = useMemo( + () => + JSON.stringify({ + spend_limit_micros: configQuery.data?.spend_limit_micros ?? null, + period: defaultLimitValues.period, + }), + [configQuery.data?.spend_limit_micros, defaultLimitValues.period], + ); + const existingGroupIds = useMemo( + () => + new Set((configQuery.data?.group_overrides ?? []).map((g) => g.group_id)), + [configQuery.data?.group_overrides], + ); + const existingUserIds = useMemo( + () => new Set((configQuery.data?.overrides ?? []).map((o) => o.user_id)), + [configQuery.data?.overrides], + ); + const availableGroups = useMemo( + () => (groupsQuery.data ?? []).filter((g) => !existingGroupIds.has(g.id)), + [groupsQuery.data, existingGroupIds], + ); + const selectedUserAlreadyOverridden = selectedUser + ? existingUserIds.has(selectedUser.id) + : false; + const groupAutocompleteNoOptionsText = groupsQuery.isLoading + ? "Loading groups..." + : (groupsQuery.data?.length ?? 0) === 0 + ? "No groups configured" + : availableGroups.length === 0 + ? "All groups already have overrides" + : "No groups available"; + + const resetUpdateConfigMutation = () => { + if (!updateConfigMutation.isPending) { + updateConfigMutation.reset(); + } + }; + + const handleSaveDefault = async ({ + enabled, + period, + amountDollars, + }: DefaultLimitFormValues) => { + const spendLimitMicros = enabled ? dollarsToMicros(amountDollars) : null; + try { + await updateConfigMutation.mutateAsync({ + spend_limit_micros: spendLimitMicros, + period, + updated_at: new Date().toISOString(), + }); + } catch { + // Keep the current form state so the inline mutation error is visible. + } + }; + + const handleAddOverride = async () => { + if (!selectedUser || !isPositiveFiniteDollarAmount(userOverrideAmount)) { + return; + } + try { + await upsertOverrideMutation.mutateAsync({ + userID: selectedUser.id, + req: { spend_limit_micros: dollarsToMicros(userOverrideAmount) }, + }); + setSelectedUser(null); + setUserOverrideAmount(""); + setShowUserForm(false); + } catch { + // Keep the current form state so the inline mutation error is visible. + } + }; + + const handleAddGroupOverride = async () => { + if (!selectedGroup || !isPositiveFiniteDollarAmount(groupAmount)) { + return; + } + try { + await upsertGroupOverrideMutation.mutateAsync({ + groupID: selectedGroup.id, + req: { spend_limit_micros: dollarsToMicros(groupAmount) }, + }); + setSelectedGroup(null); + setGroupAmount(""); + setShowGroupForm(false); + } catch { + // Keep the current form state so the inline mutation error is visible. + } + }; + + const handleDeleteGroupOverride = async (groupID: string) => { + try { + await deleteGroupOverrideMutation.mutateAsync(groupID); + } catch { + // Keep the current UI state so the inline mutation error is visible. + } + }; + + const handleDeleteOverride = async (userID: string) => { + try { + await deleteOverrideMutation.mutateAsync(userID); + } catch { + // Keep the current UI state so the inline mutation error is visible. + } + }; + + if (configQuery.isLoading) { + return ( +
+
+ +
+
+ ); + } + + if (configQuery.isError) { + return ( +
+
+
+

+ {getErrorMessage( + configQuery.error, + "Failed to load spend limit settings.", + )} +

+ +
+
+
+ ); + } + + const groupOverrides = configQuery.data?.group_overrides ?? []; + const overrides = configQuery.data?.overrides ?? []; + const unpricedModelCount = configQuery.data?.unpriced_model_count ?? 0; + + return ( +
+ + {({ + enabled, + onEnabledChange, + period, + onPeriodChange, + amountDollars, + onAmountDollarsChange, + isAmountValid, + saveDefault, + }) => ( + <> +
+
+ } + enabled={enabled} + onEnabledChange={(nextEnabled) => { + resetUpdateConfigMutation(); + onEnabledChange(nextEnabled); + }} + period={period} + onPeriodChange={(nextPeriod) => { + resetUpdateConfigMutation(); + onPeriodChange(nextPeriod); + }} + amountDollars={amountDollars} + onAmountDollarsChange={(nextAmountDollars) => { + resetUpdateConfigMutation(); + onAmountDollarsChange(nextAmountDollars); + }} + unpricedModelCount={unpricedModelCount} + panelClassName={sectionPanelClassName} + /> + + + + +
+
+ +
+
+ {updateConfigMutation.isError && ( +

+ {getErrorMessage( + updateConfigMutation.error, + "Failed to save the default spend limit.", + )} +

+ )} + {updateConfigMutation.isSuccess && ( +

Saved!

+ )} +
+ +
+ + )} +
+
+ ); +}; diff --git a/site/src/pages/AgentsPage/LimitsTab/UserOverridesSection.tsx b/site/src/pages/AgentsPage/LimitsTab/UserOverridesSection.tsx new file mode 100644 index 0000000000..b833348a9f --- /dev/null +++ b/site/src/pages/AgentsPage/LimitsTab/UserOverridesSection.tsx @@ -0,0 +1,204 @@ +import { getErrorMessage } from "api/errors"; +import type { User } from "api/typesGenerated"; +import { AvatarData } from "components/Avatar/AvatarData"; +import { Button } from "components/Button/Button"; +import { Input } from "components/Input/Input"; +import { Label } from "components/Label/Label"; +import { Spinner } from "components/Spinner/Spinner"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "components/Table/Table"; +import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete"; +import { type FC, useId } from "react"; +import { formatCostMicros, isPositiveFiniteDollarAmount } from "utils/currency"; +import { SectionHeader } from "../SectionHeader"; + +interface UserOverridesSectionProps { + overrides: ReadonlyArray<{ + user_id: string; + name: string; + username: string; + avatar_url: string; + spend_limit_micros: number | null; + }>; + panelClassName: string; + showUserForm: boolean; + onShowUserFormChange: (show: boolean) => void; + selectedUser: User | null; + onSelectedUserChange: (user: User | null) => void; + userOverrideAmount: string; + onUserOverrideAmountChange: (amount: string) => void; + selectedUserAlreadyOverridden: boolean; + onAddOverride: () => void; + onDeleteOverride: (userID: string) => void; + upsertPending: boolean; + upsertError: Error | null; + deletePending: boolean; + deleteError: Error | null; +} + +export const UserOverridesSection: FC = ({ + overrides, + panelClassName, + showUserForm, + onShowUserFormChange, + selectedUser, + onSelectedUserChange, + userOverrideAmount, + onUserOverrideAmountChange, + selectedUserAlreadyOverridden, + onAddOverride, + onDeleteOverride, + upsertPending, + upsertError, + deletePending, + deleteError, +}) => { + const userOverrideAmountId = useId(); + + return ( +
+ + +
+ {overrides.length > 0 ? ( + + + + User + Spend Limit + Actions + + + + {overrides.map((override) => ( + + + + + + {override.spend_limit_micros !== null + ? formatCostMicros(override.spend_limit_micros) + : "Unlimited"} + + + + + + ))} + +
+ ) : ( +
+ No overrides configured. +
+ )} + + {deleteError && ( +

+ {getErrorMessage(deleteError, "Failed to delete override.")} +

+ )} + + {!showUserForm ? ( + + ) : ( +
+
+
+ +
+
+ + + onUserOverrideAmountChange(event.target.value) + } + placeholder="0.00" + /> +
+
+ + +
+
+
+ )} + {selectedUserAlreadyOverridden && ( +

+ This user already has an override. +

+ )} + {upsertError && ( +

+ {getErrorMessage(upsertError, "Failed to save the override.")} +

+ )} +
+
+ ); +}; diff --git a/site/src/pages/AgentsPage/LimitsTab/index.ts b/site/src/pages/AgentsPage/LimitsTab/index.ts new file mode 100644 index 0000000000..a4213acf8f --- /dev/null +++ b/site/src/pages/AgentsPage/LimitsTab/index.ts @@ -0,0 +1 @@ +export { LimitsTab } from "./LimitsTab"; diff --git a/site/src/pages/AgentsPage/LimitsTab/limitsFormLogic.test.ts b/site/src/pages/AgentsPage/LimitsTab/limitsFormLogic.test.ts new file mode 100644 index 0000000000..97307cee08 --- /dev/null +++ b/site/src/pages/AgentsPage/LimitsTab/limitsFormLogic.test.ts @@ -0,0 +1,10 @@ +import { normalizeChatUsageLimitPeriod } from "./limitsFormLogic"; + +describe("limitsFormLogic", () => { + describe("normalizeChatUsageLimitPeriod", () => { + it("defaults invalid periods to month", () => { + expect(normalizeChatUsageLimitPeriod("year")).toBe("month"); + expect(normalizeChatUsageLimitPeriod(undefined)).toBe("month"); + }); + }); +}); diff --git a/site/src/pages/AgentsPage/LimitsTab/limitsFormLogic.ts b/site/src/pages/AgentsPage/LimitsTab/limitsFormLogic.ts new file mode 100644 index 0000000000..0b83c23e93 --- /dev/null +++ b/site/src/pages/AgentsPage/LimitsTab/limitsFormLogic.ts @@ -0,0 +1,14 @@ +import type { ChatUsageLimitPeriod } from "api/typesGenerated"; + +export const normalizeChatUsageLimitPeriod = ( + period: string | null | undefined, +): ChatUsageLimitPeriod => { + switch (period) { + case "day": + case "week": + case "month": + return period; + default: + return "month"; + } +}; diff --git a/site/src/pages/AgentsPage/usageLimitMessage.test.ts b/site/src/pages/AgentsPage/usageLimitMessage.test.ts new file mode 100644 index 0000000000..cc4b787ecb --- /dev/null +++ b/site/src/pages/AgentsPage/usageLimitMessage.test.ts @@ -0,0 +1,149 @@ +import { + type ChatDetailError, + formatUsageLimitMessage, + isUsageLimitData, +} from "./usageLimitMessage"; + +describe("formatUsageLimitMessage", () => { + it("formats a full structured message", () => { + const result = formatUsageLimitMessage({ + spent_micros: 900_000, + limit_micros: 500_000, + resets_at: "2026-03-16T00:00:00Z", + }); + expect(result).toContain("$0.90"); + expect(result).toContain("$0.50"); + expect(result).toContain("Mar"); + expect(result).toContain("2026"); + }); + + it("returns fallback when fields are missing", () => { + expect(formatUsageLimitMessage({})).toBe( + "Your usage limit has been reached.", + ); + expect(formatUsageLimitMessage({ spent_micros: 100 })).toBe( + "Your usage limit has been reached.", + ); + }); + + it("returns fallback for custom fallback message", () => { + expect(formatUsageLimitMessage({}, "Custom fallback.")).toBe( + "Custom fallback.", + ); + }); + + it("formats zero-value amounts", () => { + const result = formatUsageLimitMessage({ + spent_micros: 0, + limit_micros: 0, + resets_at: "2026-03-16T00:00:00Z", + }); + expect(result).toContain("$0.00"); + }); + + it("formats high-value amounts with locale grouping", () => { + const result = formatUsageLimitMessage({ + spent_micros: 1_234_560_000, + limit_micros: 5_000_000_000, + resets_at: "2026-03-16T00:00:00Z", + }); + expect(result).toContain("$1,234.56"); + expect(result).toContain("$5,000.00"); + }); + + it("formats sub-cent values with four decimal places", () => { + const result = formatUsageLimitMessage({ + spent_micros: 500, + limit_micros: 1_000, + resets_at: "2026-03-16T00:00:00Z", + }); + expect(result).toContain("$0.0005"); + expect(result).toContain("$0.0010"); + }); + + it("handles invalid resets_at gracefully", () => { + const result = formatUsageLimitMessage({ + spent_micros: 900_000, + limit_micros: 500_000, + resets_at: "not-a-date", + }); + expect(result).toContain("$0.90"); + expect(result).toContain("$0.50"); + expect(result).not.toContain("Resets"); + }); +}); + +describe("isUsageLimitData", () => { + it("accepts a fully populated valid payload", () => { + const error: ChatDetailError = { + message: "Your usage limit has been reached.", + kind: "usage-limit", + }; + + expect(error.kind).toBe("usage-limit"); + expect( + isUsageLimitData({ + spent_micros: 900_000, + limit_micros: 500_000, + resets_at: "2026-03-16T00:00:00Z", + }), + ).toBe(true); + }); + + it("rejects null", () => { + expect(isUsageLimitData(null)).toBe(false); + }); + + it("rejects undefined", () => { + expect(isUsageLimitData(undefined)).toBe(false); + }); + + it("rejects an empty object (missing all fields)", () => { + expect(isUsageLimitData({})).toBe(false); + }); + + it("rejects when spent_micros is missing", () => { + expect( + isUsageLimitData({ + limit_micros: 500_000, + resets_at: "2026-03-16T00:00:00Z", + }), + ).toBe(false); + }); + + it("rejects when limit_micros is missing", () => { + expect( + isUsageLimitData({ + spent_micros: 900_000, + resets_at: "2026-03-16T00:00:00Z", + }), + ).toBe(false); + }); + + it("rejects when resets_at is missing", () => { + expect( + isUsageLimitData({ spent_micros: 900_000, limit_micros: 500_000 }), + ).toBe(false); + }); + + it("rejects wrong field types (string for spent_micros)", () => { + expect( + isUsageLimitData({ + spent_micros: "900000", + limit_micros: 500_000, + resets_at: "2026-03-16T00:00:00Z", + }), + ).toBe(false); + }); + + it("accepts payload with extra fields", () => { + expect( + isUsageLimitData({ + spent_micros: 900_000, + limit_micros: 500_000, + resets_at: "2026-03-16T00:00:00Z", + extra_field: "ignored", + }), + ).toBe(true); + }); +}); diff --git a/site/src/pages/AgentsPage/usageLimitMessage.ts b/site/src/pages/AgentsPage/usageLimitMessage.ts new file mode 100644 index 0000000000..3775ccf08d --- /dev/null +++ b/site/src/pages/AgentsPage/usageLimitMessage.ts @@ -0,0 +1,89 @@ +import { formatCostMicros } from "utils/currency"; + +/** + * Shape of structured usage-limit fields added to 409 responses + * from chat endpoints. + */ +interface UsageLimitData { + spent_micros?: number; + limit_micros?: number; + resets_at?: string; // RFC3339 +} + +/** + * Typed classification for errors surfaced in the agent detail view. + * - "usage-limit": the user hit a spending cap (409 + valid usage data). + * - "generic": any other error (stream failures, last_error, etc.). + */ +export type ChatDetailError = { + message: string; + kind: "generic" | "usage-limit"; +}; + +/** + * Format a resets_at RFC3339 timestamp into a user-friendly string. + * Example: "2026-03-16T00:00:00Z" → "Mar 16, 2026 at 12:00 AM" + */ +function formatResetDate(isoString: string): string { + const date = new Date(isoString); + if (Number.isNaN(date.getTime())) { + return ""; + } + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", + }); +} + +/** + * Runtime guard that validates whether an unknown value has the shape + * of structured usage-limit fields from a 409 response. + * All three fields must be present with correct types. + */ +export function isUsageLimitData(value: unknown): value is UsageLimitData { + if (value == null || typeof value !== "object") { + return false; + } + const obj = value as Record; + return ( + typeof obj.spent_micros === "number" && + typeof obj.limit_micros === "number" && + typeof obj.resets_at === "string" + ); +} + +/** + * Build a user-friendly usage-limit message from structured 409 + * response data. Falls back to a generic message if structured + * fields are missing or invalid. + */ +export function formatUsageLimitMessage( + data: UsageLimitData, + fallback = "Your usage limit has been reached.", +): string { + const { spent_micros, limit_micros, resets_at } = data; + + // All structured fields must be present and valid for the + // detailed message. + if ( + typeof spent_micros !== "number" || + typeof limit_micros !== "number" || + typeof resets_at !== "string" || + !resets_at + ) { + return fallback; + } + + const spent = formatCostMicros(spent_micros); + const limit = formatCostMicros(limit_micros); + const resetDate = formatResetDate(resets_at); + + if (!resetDate) { + return `You've used ${spent} of your ${limit} limit.`; + } + + return `You've used ${spent} of your ${limit} limit. Resets ${resetDate}.`; +} diff --git a/site/src/utils/analytics.test.ts b/site/src/utils/analytics.test.ts index 7d8326a8b8..7e8ead93c2 100644 --- a/site/src/utils/analytics.test.ts +++ b/site/src/utils/analytics.test.ts @@ -1,43 +1,5 @@ import { describe, expect, it } from "vitest"; -import { formatCostMicros, formatTokenCount } from "./analytics"; - -describe("formatCostMicros", () => { - it("formats zero values", () => { - expect(formatCostMicros(0)).toBe("$0.00"); - }); - - it("formats normal values to cents", () => { - expect(formatCostMicros(1_500_000)).toBe("$1.50"); - expect(formatCostMicros(123_456)).toBe("$0.12"); - }); - - it("formats sub-cent values with four decimal places", () => { - expect(formatCostMicros(500)).toBe("$0.0005"); - }); - - it("falls back to zero for invalid numeric values", () => { - expect(formatCostMicros("abc")).toBe("$0.00"); - expect(formatCostMicros(Number.POSITIVE_INFINITY)).toBe("$0.00"); - }); - - it("formats negative values with the minus sign before the dollar sign", () => { - expect(formatCostMicros(-1_500_000)).toBe("-$1.50"); - expect(formatCostMicros(-500)).toBe("-$0.0005"); - }); - - it("avoids confusing four-decimal output when sub-cent values round to one cent", () => { - expect(formatCostMicros(9_999)).toBe("$0.01"); - }); - - it("formats threshold values correctly", () => { - expect(formatCostMicros(10_000)).toBe("$0.01"); - expect(formatCostMicros(12_345_678)).toBe("$12.35"); - }); - - it("formats string micros from generated API types", () => { - expect(formatCostMicros("1500000")).toBe("$1.50"); - }); -}); +import { formatTokenCount } from "./analytics"; describe("formatTokenCount", () => { it("formats zero values", () => { diff --git a/site/src/utils/analytics.ts b/site/src/utils/analytics.ts index ac7caae32e..a7f6afccb2 100644 --- a/site/src/utils/analytics.ts +++ b/site/src/utils/analytics.ts @@ -1,22 +1,3 @@ -/** - * Format cost in micros (millionths of a dollar) to a currency string. - * Examples: 0 → "$0.00", 1_500_000 → "$1.50", 123_456 → "$0.12" - */ -export function formatCostMicros(micros: number | string): string { - const microsValue = typeof micros === "string" ? Number(micros) : micros; - if (Number.isNaN(microsValue) || !Number.isFinite(microsValue)) { - return "$0.00"; - } - - const sign = microsValue < 0 ? "-" : ""; - const dollars = Math.abs(microsValue) / 1_000_000; - const rounded = Number(dollars.toFixed(4)); - if (rounded > 0 && rounded < 0.01) { - return `${sign}$${dollars.toFixed(4)}`; - } - return `${sign}$${dollars.toFixed(2)}`; -} - /** * Format a token count to a compact human-readable string. * Examples: 0 → "0", 1234 → "1,234", 1_500_000 → "1.5M" diff --git a/site/src/utils/currency.test.ts b/site/src/utils/currency.test.ts new file mode 100644 index 0000000000..f258179989 --- /dev/null +++ b/site/src/utils/currency.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; + +import { + dollarsToMicros, + formatCostMicros, + isPositiveFiniteDollarAmount, + MICROS_PER_DOLLAR, + microsToDollars, +} from "./currency"; + +describe("MICROS_PER_DOLLAR", () => { + it("matches the expected million-micros constant", () => { + expect(MICROS_PER_DOLLAR).toBe(1_000_000); + }); +}); + +describe("microsToDollars", () => { + it("converts micros to dollars without rounding", () => { + expect(microsToDollars(1_500_000)).toBe(1.5); + expect(microsToDollars(500)).toBe(0.0005); + expect(microsToDollars(0)).toBe(0); + expect(microsToDollars(125_000_000)).toBe(125); + }); +}); + +describe("dollarsToMicros", () => { + it("converts string dollar amounts to micros", () => { + expect(dollarsToMicros("12.34")).toBe(12_340_000); + expect(dollarsToMicros("0.000001")).toBe(1); + }); + + it("returns zero for blank or non-finite inputs", () => { + expect(dollarsToMicros("")).toBe(0); + expect(dollarsToMicros("NaN")).toBe(0); + expect(dollarsToMicros("Infinity")).toBe(0); + }); + + it("returns zero for negative or sub-micro inputs", () => { + expect(dollarsToMicros("-1")).toBe(0); + expect(dollarsToMicros("1e-10")).toBe(0); + }); + + it("accepts number inputs", () => { + expect(dollarsToMicros(12.34)).toBe(12_340_000); + }); +}); + +describe("isPositiveFiniteDollarAmount", () => { + it("accepts positive finite dollar amounts that round to at least one micro", () => { + expect(isPositiveFiniteDollarAmount("12.34")).toBe(true); + expect(isPositiveFiniteDollarAmount("1e2")).toBe(true); + }); + + it("rejects blank, invalid, non-positive, and sub-micro values", () => { + expect(isPositiveFiniteDollarAmount("")).toBe(false); + expect(isPositiveFiniteDollarAmount("0")).toBe(false); + expect(isPositiveFiniteDollarAmount("-1")).toBe(false); + expect(isPositiveFiniteDollarAmount("abc")).toBe(false); + expect(isPositiveFiniteDollarAmount("1e-10")).toBe(false); + expect(isPositiveFiniteDollarAmount("1e309")).toBe(false); + }); +}); + +describe("formatCostMicros", () => { + it("formats zero values", () => { + expect(formatCostMicros(0)).toBe("$0.00"); + }); + + it("formats normal dollar values with two decimals", () => { + expect(formatCostMicros(1_500_000)).toBe("$1.50"); + expect(formatCostMicros(123_456)).toBe("$0.12"); + }); + + it("formats sub-cent values with four decimals", () => { + expect(formatCostMicros(500)).toBe("$0.0005"); + }); + + it("falls back to zero for invalid numeric values", () => { + expect(formatCostMicros("abc")).toBe("$0.00"); + expect(formatCostMicros(Number.POSITIVE_INFINITY)).toBe("$0.00"); + }); + + it("formats negative values with the sign before the dollar symbol", () => { + expect(formatCostMicros(-1_500_000)).toBe("-$1.50"); + expect(formatCostMicros(-500)).toBe("-$0.0005"); + }); + + it("uses the normal currency formatter when sub-cent values round to one cent", () => { + expect(formatCostMicros(9_999)).toBe("$0.01"); + }); + + it("formats threshold, rounded, string, and grouped values correctly", () => { + expect(formatCostMicros(10_000)).toBe("$0.01"); + expect(formatCostMicros(12_345_678)).toBe("$12.35"); + expect(formatCostMicros("1500000")).toBe("$1.50"); + expect(formatCostMicros(1_234_560_000)).toBe("$1,234.56"); + }); +}); diff --git a/site/src/utils/currency.ts b/site/src/utils/currency.ts new file mode 100644 index 0000000000..d52def94d1 --- /dev/null +++ b/site/src/utils/currency.ts @@ -0,0 +1,53 @@ +export const MICROS_PER_DOLLAR = 1_000_000; + +const usdCurrencyFormatter = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: 2, + maximumFractionDigits: 2, + signDisplay: "auto", +}); + +const usdSubCentCurrencyFormatter = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: 4, + maximumFractionDigits: 4, + signDisplay: "auto", +}); + +export function microsToDollars(micros: number): number { + return micros / MICROS_PER_DOLLAR; +} + +export function dollarsToMicros(dollars: string | number): number { + if (typeof dollars === "string" && dollars.trim() === "") { + return 0; + } + + const micros = Math.round(Number(dollars) * MICROS_PER_DOLLAR); + return Number.isFinite(micros) && micros > 0 ? micros : 0; +} + +export function isPositiveFiniteDollarAmount(dollars: string): boolean { + return dollars.trim() !== "" && dollarsToMicros(dollars) > 0; +} + +export function formatCostMicros(micros: number | string): string { + const microsValue = Number(micros); + if (!Number.isFinite(microsValue)) { + return "$0.00"; + } + + const dollars = Math.abs(microsValue) / MICROS_PER_DOLLAR; + const rounded4 = Number(dollars.toFixed(4)); + if (rounded4 > 0 && rounded4 < 0.01) { + if (microsValue < 0) { + return `-$${dollars.toFixed(4)}`; + } + + return usdSubCentCurrencyFormatter.format(dollars); + } + + return usdCurrencyFormatter.format(microsToDollars(microsValue)); +}