feat(site): add chat spend limit UI (#23072)

Frontend for agent chat spend limiting on `/agents`.

## Changes
- add the limits management UI, API hooks, and validation for
deployment, group, and user overrides
- show spend limit status in Agents analytics and usage summaries
- surface limit-related chat errors consistently in the agent detail
experience
- add shared currency and usage-limit messaging helpers plus related
stories/tests
This commit is contained in:
Michael Suchacz
2026-03-17 02:01:51 +01:00
committed by GitHub
parent 1031da9738
commit 7cca2b6176
34 changed files with 2477 additions and 603 deletions
+57
View File
@@ -3198,6 +3198,63 @@ class ApiMethods {
const response = await this.axios.get<TypesGen.ChatCostUsersResponse>(url);
return response.data;
};
getChatUsageLimitConfig =
async (): Promise<TypesGen.ChatUsageLimitConfigResponse> => {
const response =
await this.axios.get<TypesGen.ChatUsageLimitConfigResponse>(
"/api/experimental/chats/usage-limits",
);
return response.data;
};
updateChatUsageLimitConfig = async (
req: TypesGen.ChatUsageLimitConfig,
): Promise<TypesGen.ChatUsageLimitConfig> => {
const response = await this.axios.put<TypesGen.ChatUsageLimitConfig>(
"/api/experimental/chats/usage-limits",
req,
);
return response.data;
};
upsertChatUsageLimitOverride = async (
userID: string,
req: TypesGen.UpsertChatUsageLimitOverrideRequest,
): Promise<TypesGen.ChatUsageLimitOverride> => {
const response = await this.axios.put<TypesGen.ChatUsageLimitOverride>(
`/api/experimental/chats/usage-limits/overrides/${encodeURIComponent(userID)}`,
req,
);
return response.data;
};
deleteChatUsageLimitOverride = async (userID: string): Promise<void> => {
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<TypesGen.ChatUsageLimitGroupOverride> => {
const response = await this.axios.put<TypesGen.ChatUsageLimitGroupOverride>(
`/api/experimental/chats/usage-limits/group-overrides/${encodeURIComponent(groupID)}`,
req,
);
return response.data;
};
deleteChatUsageLimitGroupOverride = async (
groupID: string,
): Promise<void> => {
const response = await this.axios.delete(
`/api/experimental/chats/usage-limits/group-overrides/${encodeURIComponent(groupID)}`,
);
return response.data;
};
}
export type TaskFeedbackRating = "good" | "okay" | "bad";
+76
View File
@@ -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,
});
},
});
+2 -1
View File
@@ -10,7 +10,8 @@ type GroupSortOrder = "asc" | "desc";
export const groupsQueryKey = ["groups"];
const groups = () => {
/** @public */
export const groups = () => {
return {
queryKey: groupsQueryKey,
queryFn: () => API.getGroups(),
@@ -23,7 +23,7 @@ type SerializedFileReferenceNode = Spread<
SerializedLexicalNode
>;
export function FileReferenceChip({
function FileReferenceChip({
fileName,
startLine,
endLine,
@@ -41,6 +41,8 @@ const meta: Meta<typeof AgentCreateForm> = {
export default meta;
type Story = StoryObj<typeof AgentCreateForm>;
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: () => ({}),
},
),
},
};
+27 -1
View File
@@ -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<AgentCreateFormProps> = ({
@@ -122,6 +127,7 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
isModelCatalogLoading,
isModelConfigsLoading,
modelCatalogError,
onOpenAnalytics,
}) => {
const { organizations } = useDashboard();
const { initialInputValue, handleContentChange, submitDraft, resetDraft } =
@@ -326,7 +332,27 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
return (
<div className="flex min-h-0 flex-1 items-start justify-center overflow-auto p-4 pt-12 md:h-full md:items-center md:pt-4">
<div className="mx-auto flex w-full max-w-3xl flex-col gap-4">
{createError ? <ErrorAlert error={createError} /> : null}
{createError ? (
isApiError(createError) &&
createError.response?.status === 409 &&
isUsageLimitData(createError.response.data) ? (
<Alert
severity="info"
className="py-2"
actions={
onOpenAnalytics && (
<Button variant="subtle" size="sm" onClick={onOpenAnalytics}>
View Usage
</Button>
)
}
>
{formatUsageLimitMessage(createError.response.data)}
</Alert>
) : (
<ErrorAlert error={createError} />
)
) : null}
{workspacesQuery.isError && (
<ErrorAlert error={workspacesQuery.error} />
)}
+65 -353
View File
@@ -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<typeof useChatStore>["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<AgentDetailTimelineProps> = ({
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 (
<ConversationTimeline
isEmpty={messages.length === 0}
hasMoreMessages={hasMoreMessages}
loadMoreSentinelRef={loadMoreSentinelRef}
parsedSections={parsedSections}
hasStreamOutput={hasStreamOutput}
streamState={streamState}
streamTools={streamTools}
subagentTitles={subagentTitles}
subagentStatusOverrides={subagentStatusOverrides}
retryState={retryState}
isAwaitingFirstStreamChunk={isAwaitingFirstStreamChunk}
detailErrorMessage={detailErrorMessage}
onEditUserMessage={onEditUserMessage}
editingMessageId={editingMessageId}
savingMessageId={savingMessageId}
urlTransform={urlTransform}
/>
);
};
interface AgentDetailInputProps {
store: ChatStoreHandle;
compressionThreshold: number | undefined;
onSend: (message: string, fileIds?: string[]) => void;
onDeleteQueuedMessage: (id: number) => Promise<void>;
onPromoteQueuedMessage: (id: number) => Promise<void>;
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<ChatMessageInputRef>;
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<AgentDetailInputProps> = ({
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<File, UploadState>();
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 (
<AgentChatInput
onSend={(message) => {
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}
@@ -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 () => {
@@ -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",
@@ -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();
},
};
@@ -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<RenderBlock, { type: "response" }>
| Extract<RenderBlock, { type: "file-reference" }> =>
b.type === "response" || b.type === "file-reference",
)
: [];
const conversationItemProps: { role: "user" | "assistant" } = {
role: isUser ? "user" : "assistant",
};
@@ -407,20 +395,7 @@ const ChatMessageItem = memo<{
<div className="flex flex-col gap-1.5">
<div className="flex items-start gap-2">
<span className="min-w-0 flex-1">
{userInlineContent.length > 0
? userInlineContent.map((block, i) =>
block.type === "response" ? (
<Fragment key={i}>{block.text}</Fragment>
) : (
<FileReferenceChip
key={i}
fileName={block.fileName}
startLine={block.startLine}
endLine={block.endLine}
/>
),
)
: parsed.markdown || ""}
{parsed.markdown || ""}
</span>
{isSavingMessage && (
<Spinner
@@ -463,7 +438,45 @@ const ChatMessageItem = memo<{
</div>
);
})()}
{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 (
<div className="flex flex-col gap-1 border-t border-border-default pt-1.5">
{fileRefBlocks.map((dc, i) => (
<div
key={i}
className="flex items-start gap-2 rounded border border-content-link/20 bg-content-link/5 px-2 py-1"
>
<FileIcon
fileName={
dc.fileName.split("/").pop() || dc.fileName
}
className="shrink-0"
/>
<span className="shrink-0 text-2xs font-mono font-medium text-content-link">
{dc.fileName.split("/").pop()}:
{dc.startLine === dc.endLine
? dc.startLine
: `${dc.startLine}\u2013${dc.endLine}`}
</span>
{dc.text && (
<span className="text-2xs text-content-primary">
{dc.text}
</span>
)}
</div>
))}
</div>
);
})()} {fadeFromBottom && (
<div
className="pointer-events-none absolute inset-x-0 bottom-0 h-1/2 max-h-12"
style={{
@@ -652,10 +665,7 @@ const StickyUserMessage: FC<{
const scroller = sentinel.closest(".overflow-y-auto") as HTMLElement | null;
if (!scroller) return;
// Halve the minimum visible height on mobile so the
// sticky message takes up less vertical space.
const isMobile = window.matchMedia("(max-width: 639px)").matches;
const MIN_HEIGHT = isMobile ? 52 : 72;
const MIN_HEIGHT = 72;
let scrollerTop = scroller.getBoundingClientRect().top;
let scrollerHeight = scroller.clientHeight;
@@ -769,7 +779,7 @@ const StickyUserMessage: FC<{
<div
ref={containerRef}
className={cn(
"relative px-3 -mx-3 pt-0.5 pb-0.5 sm:pt-2 sm:pb-2",
"relative px-3 -mx-3 pt-2 pb-2",
!isTooTall && "sticky top-0 z-10",
!isReady && "invisible",
isStuck && !isTooTall && "pointer-events-none",
@@ -808,29 +818,26 @@ const StickyUserMessage: FC<{
}}
>
{/* 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. */}
<div
className="absolute inset-0 backdrop-blur-[1px] bg-surface-primary/15"
style={{
maxHeight: "calc(var(--clip-h, 100%) + 48px)",
willChange: "max-height, mask-image",
maskImage:
"linear-gradient(to bottom, black calc(var(--clip-h, 100%) - 24px), transparent calc(var(--clip-h, 100%) + 48px))",
"linear-gradient(to bottom, black calc(var(--clip-h, 100%) + 24px), transparent calc(var(--clip-h, 100%) + 48px))",
WebkitMaskImage:
"linear-gradient(to bottom, black calc(var(--clip-h, 100%) - 24px), transparent calc(var(--clip-h, 100%) + 48px))",
"linear-gradient(to bottom, black calc(var(--clip-h, 100%) + 24px), transparent calc(var(--clip-h, 100%) + 48px))",
}}
/>
{/* 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. */}
<div
className="relative px-3 pt-0.5 sm:pt-2 pointer-events-auto"
className="relative px-3 pt-2 pointer-events-auto"
style={{ willChange: "max-height" }}
>
<ChatMessageItem
@@ -861,7 +868,8 @@ interface ConversationTimelineProps {
subagentStatusOverrides: Map<string, TypesGen.ChatStatus>;
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<ConversationTimelineProps> = ({
subagentStatusOverrides,
retryState,
isAwaitingFirstStreamChunk,
detailErrorMessage,
detailError,
onOpenAnalytics,
onEditUserMessage,
editingMessageId,
savingMessageId,
@@ -892,9 +901,11 @@ export const ConversationTimeline: FC<ConversationTimelineProps> = ({
}) => {
const shouldRenderStreamInLastSection =
hasStreamOutput && parsedSections.length > 0;
const isUsageLimitError = detailError?.kind === "usage-limit";
const showUsageAction = onOpenAnalytics !== undefined && isUsageLimitError;
return (
<div className="mx-auto w-full max-w-3xl py-3 sm:py-6">
<div className="mx-auto w-full max-w-3xl py-6">
{isEmpty && !hasStreamOutput ? (
<div className="py-12 text-center text-content-secondary">
<p className="text-sm">Start a conversation with your agent.</p>
@@ -967,10 +978,20 @@ export const ConversationTimeline: FC<ConversationTimelineProps> = ({
)}
</div>
)}
{detailErrorMessage && (
<div className="mt-4 rounded-md border border-border-destructive bg-surface-red px-3 py-2 text-xs text-content-destructive">
{detailErrorMessage}
</div>
{detailError && (
<Alert
severity={isUsageLimitError ? "info" : "error"}
className="py-2"
actions={
showUsageAction && (
<Button variant="subtle" size="sm" onClick={onOpenAnalytics}>
View Usage
</Button>
)
}
>
{detailError.message}
</Alert>
)}
</div>
);
@@ -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<typeof useChatStore>["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<AgentDetailTimelineProps> = ({
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 (
<ConversationTimeline
isEmpty={messages.length === 0}
hasMoreMessages={hasMoreMessages}
loadMoreSentinelRef={loadMoreSentinelRef}
parsedSections={parsedSections}
hasStreamOutput={hasStreamOutput}
streamState={streamState}
streamTools={streamTools}
subagentTitles={subagentTitles}
subagentStatusOverrides={subagentStatusOverrides}
retryState={retryState}
isAwaitingFirstStreamChunk={isAwaitingFirstStreamChunk}
detailError={detailError}
onOpenAnalytics={onOpenAnalytics}
onEditUserMessage={onEditUserMessage}
editingMessageId={editingMessageId}
savingMessageId={savingMessageId}
urlTransform={urlTransform}
/>
);
};
interface AgentDetailInputProps {
store: ChatStoreHandle;
compressionThreshold: number | undefined;
onSend: (message: string, fileIds?: string[]) => void;
onDeleteQueuedMessage: (id: number) => Promise<void>;
onPromoteQueuedMessage: (id: number) => Promise<void>;
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<ChatMessageInputRef>;
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<AgentDetailInputProps> = ({
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<File, UploadState>();
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 (
<AgentChatInput
onSend={(message) => {
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}
/>
);
};
@@ -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" },
},
},
};
+16 -4
View File
@@ -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<typeof useChatStore>["store"];
@@ -53,7 +58,7 @@ interface AgentDetailViewProps {
agentId: string;
chatTitle: string | undefined;
parentChat: TypesGen.Chat | undefined;
chatErrorReasons: Record<string, string>;
chatErrorReasons: Record<string, ChatDetailError>;
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<AgentDetailViewProps> = ({
isInterruptPending,
isSidebarCollapsed,
onToggleSidebarCollapsed,
onOpenAnalytics,
showSidebarPanel,
onSetShowSidebarPanel,
prNumber,
@@ -186,6 +193,7 @@ export const AgentDetailView: FC<AgentDetailViewProps> = ({
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<AgentDetailViewProps> = ({
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}
+24 -15
View File
@@ -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<Promise<unknown> | null>(null);
const [chatErrorReasons, setChatErrorReasons] = useState<
Record<string, string>
Record<string, ChatDetailError>
>({});
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) {
+29 -16
View File
@@ -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<string, string>
Record<string, ChatDetailError>
>({});
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<TypesGen.Chat | undefined>(
@@ -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(),
+27 -9
View File
@@ -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<string, string>;
setChatErrorReason: (chatId: string, reason: string) => void;
chatErrorReasons: Record<string, ChatDetailError>;
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<AgentsPageViewProps> = ({
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 (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-surface-primary md:flex-row">
<title>{pageTitle("Agents")}</title>
@@ -123,7 +143,7 @@ export const AgentsPageView: FC<AgentsPageViewProps> = ({
>
<AgentsSidebar
chats={chatList}
chatErrorReasons={chatErrorReasons}
chatErrorReasons={sidebarChatErrorReasons}
modelOptions={catalogModelOptions}
modelConfigs={modelConfigs}
logoUrl={logoUrl}
@@ -143,10 +163,7 @@ export const AgentsPageView: FC<AgentsPageViewProps> = ({
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<AgentsPageViewProps> = ({
)}
>
{agentId ? (
<Outlet key={agentId} context={outletContext} />
<Outlet key={agentId} context={outletContextValue} />
) : (
<>
<div className="flex shrink-0 items-center gap-2 px-4 py-0.5">
@@ -203,6 +220,7 @@ export const AgentsPageView: FC<AgentsPageViewProps> = ({
isModelCatalogLoading={isModelCatalogLoading}
isModelConfigsLoading={isModelConfigsLoading}
modelCatalogError={modelCatalogError}
onOpenAnalytics={handleOpenAnalytics}
/>
</>
)}
@@ -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<ChatCostSummaryViewProps> = ({
summary,
isLoading,
@@ -60,6 +81,41 @@ export const ChatCostSummaryView: FC<ChatCostSummaryViewProps> = ({
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 (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-4 md:grid-cols-3">
@@ -115,6 +171,59 @@ export const ChatCostSummaryView: FC<ChatCostSummaryViewProps> = ({
</div>
</div>
{showUsageLimitCard && usageLimit && (
<div className="rounded-lg border border-border-default bg-surface-secondary p-4">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
<div>
<p className="text-xs font-medium uppercase tracking-wide text-content-secondary">
{usageLimitPeriodLabel} Spend Limit
</p>
{usageLimitCurrentPeriod && (
<p className="mt-1 text-sm text-content-secondary">
{usageLimitCurrentPeriod}
</p>
)}
<p className="mt-1 text-2xl font-semibold text-content-primary">
{formatCostMicros(usageLimitCurrentSpend)} /{" "}
{formatCostMicros(usageLimitSpendMicros)}
</p>
</div>
<p className="text-sm text-content-secondary">
{Math.round(usageProgressPercentage)}% used
</p>
</div>
<div
role="progressbar"
aria-label={`${usageLimitPeriodLabel} spend usage`}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(usageProgressPercentage)}
className="h-2 overflow-hidden rounded-full bg-surface-tertiary"
>
<div
className={`h-full rounded-full ${usageProgressBarClass}`}
style={{ width: `${usageProgressPercentage}%` }}
/>
</div>
<div className="flex flex-col gap-1 text-sm md:flex-row md:items-center md:justify-between">
<p
className={
usageLimitExceeded
? "text-content-destructive"
: "text-content-secondary"
}
>
{usageLimitStatusText}
</p>
<p className="text-content-secondary">
Resets {usageLimitResetAt}
</p>
</div>
</div>
</div>
)}
{summary.unpriced_message_count > 0 && (
<div className="flex items-start gap-3 rounded-lg border border-border-warning bg-surface-warning p-4 text-sm text-content-primary">
<TriangleAlertIcon className="h-5 w-5 shrink-0 text-content-warning" />
@@ -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<typeof ConfigureAgentsDialog> = {
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();
},
};
@@ -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<ConfigureAgentsDialogProps> = ({
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<ConfigureAgentsDialogProps> = ({
})}
</nav>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-6 py-5 [scrollbar-width:thin] [scrollbar-color:hsl(var(--surface-quaternary))_transparent]">
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
{activeSection === "behavior" && (
<>
<div className="flex-1 overflow-y-auto px-6 py-5 [scrollbar-width:thin] [scrollbar-color:hsl(var(--surface-quaternary))_transparent]">
<SectionHeader
label="Behavior"
description="Custom instructions that shape how the agent responds in your chats."
@@ -623,26 +633,35 @@ export const ConfigureAgentsDialog: FC<ConfigureAgentsDialogProps> = ({
</form>
</>
)}
</>
</div>
)}
{activeSection === "providers" && canManageChatModelConfigs && (
<ChatModelAdminPanel
section="providers"
sectionLabel="Providers"
sectionDescription="Connect third-party LLM services like OpenAI, Anthropic, or Google. Each provider supplies models that users can select for their chats."
sectionBadge={<AdminBadge />}
/>
<div className="flex-1 overflow-y-auto px-6 py-5 [scrollbar-width:thin] [scrollbar-color:hsl(var(--surface-quaternary))_transparent]">
<ChatModelAdminPanel
section="providers"
sectionLabel="Providers"
sectionDescription="Connect third-party LLM services like OpenAI, Anthropic, or Google. Each provider supplies models that users can select for their chats."
sectionBadge={<AdminBadge />}
/>
</div>
)}
{activeSection === "models" && canManageChatModelConfigs && (
<ChatModelAdminPanel
section="models"
sectionLabel="Models"
sectionDescription="Choose which models from your configured providers are available for users to select. You can set a default and adjust context limits."
sectionBadge={<AdminBadge />}
/>
<div className="flex-1 overflow-y-auto px-6 py-5 [scrollbar-width:thin] [scrollbar-color:hsl(var(--surface-quaternary))_transparent]">
<ChatModelAdminPanel
section="models"
sectionLabel="Models"
sectionDescription="Choose which models from your configured providers are available for users to select. You can set a default and adjust context limits."
sectionBadge={<AdminBadge />}
/>
</div>
)}
{activeSection === "limits" && canManageChatModelConfigs && (
<LimitsTab />
)}
{activeSection === "usage" && canManageChatModelConfigs && (
<UsageContent now={now} />
<div className="flex-1 overflow-y-auto px-6 py-5 [scrollbar-width:thin] [scrollbar-color:hsl(var(--surface-quaternary))_transparent]">
<UsageContent now={now} />
</div>
)}
</div>
</DialogContent>
@@ -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<DefaultLimitSectionProps> = ({
enabled,
onEnabledChange,
period,
onPeriodChange,
amountDollars,
onAmountDollarsChange,
unpricedModelCount,
adminBadge,
panelClassName,
}) => {
const periodId = useId();
const amountId = useId();
return (
<>
<SectionHeader
label="Default Spend Limit"
description="Set a deployment-wide spend cap that applies to all users by default."
badge={adminBadge}
/>
<div className={panelClassName}>
<div className="flex items-center justify-between gap-4">
<div>
<p className="m-0 text-sm font-medium text-content-primary">
Enable spend limit
</p>
<p className="m-0 text-xs text-content-secondary">
When disabled, users have unlimited spending.
</p>
</div>
<Switch
checked={enabled}
onCheckedChange={onEnabledChange}
aria-label="Enable spend limit"
/>
</div>
{enabled && (
<div className="flex flex-col gap-3 md:flex-row md:items-end">
<div className="flex-1 space-y-1">
<div className="flex items-center gap-1">
<Label htmlFor={periodId}>Period</Label>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<InfoIcon className="h-3.5 w-3.5 shrink-0 cursor-help text-content-secondary" />
</TooltipTrigger>
<TooltipContent>
Only one period can be active at a time. Spend is
calculated from the start of the current period.
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<Select
value={period}
onValueChange={(value) =>
onPeriodChange(value as ChatUsageLimitPeriod)
}
>
<SelectTrigger
id={periodId}
className="h-9 min-w-0 text-[13px]"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="day">Day</SelectItem>
<SelectItem value="week">Week</SelectItem>
<SelectItem value="month">Month</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex-1 space-y-1">
<Label htmlFor={amountId}>Amount ($)</Label>
<Input
id={amountId}
type="number"
step="0.01"
min="0"
className="h-9 min-w-0 text-[13px]"
value={amountDollars}
onChange={(event) => onAmountDollarsChange(event.target.value)}
placeholder="0.00"
/>
</div>
</div>
)}
</div>
{enabled && unpricedModelCount > 0 && (
<div className="flex items-start gap-3 rounded-lg border border-border-warning bg-surface-warning p-4 text-sm text-content-primary">
<TriangleAlertIcon className="h-5 w-5 shrink-0 text-content-warning" />
<div>
{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.
</div>
</div>
)}
</>
);
};
@@ -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<GroupLimitsSectionProps> = ({
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 (
<section className="space-y-4">
<SectionHeader
label="Group Limits"
description="Override the default limit for specific groups. When a user belongs to multiple groups, the lowest group limit applies."
/>
<div className={panelClassName}>
{groupOverrides.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Group</TableHead>
<TableHead>Members</TableHead>
<TableHead>Spend Limit</TableHead>
<TableHead className="w-[80px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{groupOverrides.map((override) => (
<TableRow key={override.group_id}>
<TableCell>
<AvatarData
title={override.group_display_name || override.group_name}
subtitle={override.group_name}
src={override.group_avatar_url}
imgFallbackText={override.group_name}
/>
</TableCell>
<TableCell>{override.member_count}</TableCell>
<TableCell>
{override.spend_limit_micros !== null
? formatCostMicros(override.spend_limit_micros)
: "Unlimited"}
</TableCell>
<TableCell>
<Button
variant="outline"
size="sm"
type="button"
onClick={() =>
void onDeleteGroupOverride(override.group_id)
}
disabled={deletePending}
>
Delete
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className="rounded-lg border border-border bg-surface-secondary px-4 py-6 text-center text-sm text-content-secondary">
No group overrides configured.
</div>
)}
{deleteError && (
<p className="text-xs text-content-destructive">
{getErrorMessage(deleteError, "Failed to delete group override.")}
</p>
)}
{!showGroupForm ? (
<Button
variant="outline"
size="sm"
type="button"
onClick={() => onShowGroupFormChange(true)}
disabled={groupsLoading || availableGroups.length === 0}
>
Add Group
</Button>
) : (
<div className="space-y-3 rounded-lg border border-border bg-surface-secondary/40 p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-end">
<div className="flex-1 space-y-1">
<Label htmlFor={groupAutocompleteId}>Group</Label>
<Autocomplete
id={groupAutocompleteId}
value={selectedGroup}
onChange={onSelectedGroupChange}
options={availableGroups}
getOptionValue={(group) => group.id}
getOptionLabel={(group) => group.display_name || group.name}
isOptionEqualToValue={(option, optionValue) =>
option.id === optionValue.id
}
renderOption={(option, isSelected) => (
<div className="flex w-full items-center justify-between gap-2">
<AvatarData
title={option.display_name || option.name}
subtitle={getGroupSubtitle(option)}
src={option.avatar_url}
imgFallbackText={option.name}
/>
{isSelected && <Check className="size-4 shrink-0" />}
</div>
)}
placeholder="Search groups..."
noOptionsText={groupAutocompleteNoOptionsText}
loading={groupsLoading}
disabled={groupsLoading}
className="w-full"
/>
</div>
<div className="flex-1 space-y-1">
<Label htmlFor={groupAmountId}>Spend Limit ($)</Label>
<Input
id={groupAmountId}
type="number"
step="0.01"
min="0"
className="h-9 min-w-0 text-[13px]"
value={groupAmount}
onChange={(event) => onGroupAmountChange(event.target.value)}
placeholder="0.00"
/>
</div>
<div className="flex gap-2 md:pb-0.5">
<Button
size="sm"
type="button"
onClick={() => void onAddGroupOverride()}
disabled={
upsertPending ||
selectedGroup === null ||
!isPositiveFiniteDollarAmount(groupAmount)
}
>
{upsertPending ? (
<Spinner loading className="h-4 w-4" />
) : null}
Add
</Button>
<Button
variant="outline"
size="sm"
type="button"
onClick={() => {
onShowGroupFormChange(false);
onSelectedGroupChange(null);
onGroupAmountChange("");
}}
>
Cancel
</Button>
</div>
</div>
</div>
)}
{upsertError && (
<p className="text-xs text-content-destructive">
{getErrorMessage(upsertError, "Failed to save group override.")}
</p>
)}
{groupsError && (
<p className="text-xs text-content-destructive">
{getErrorMessage(groupsError, "Failed to load groups.")}
</p>
)}
</div>
</section>
);
};
@@ -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<void>;
children: (props: {
enabled: boolean;
onEnabledChange: (enabled: boolean) => void;
period: ChatUsageLimitPeriod;
onPeriodChange: (period: ChatUsageLimitPeriod) => void;
amountDollars: string;
onAmountDollarsChange: (amount: string) => void;
isAmountValid: boolean;
saveDefault: () => Promise<void>;
}) => ReactNode;
}
const AdminBadge: FC = () => (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex cursor-default items-center gap-1 rounded bg-surface-tertiary/60 px-1.5 py-px text-[11px] font-medium text-content-secondary">
<ShieldIcon className="h-3 w-3" />
Admin
</span>
</TooltipTrigger>
<TooltipContent side="right">
Only visible to deployment administrators.
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
const DefaultLimitController: FC<DefaultLimitControllerProps> = ({
initialValues,
onSave,
children,
}) => {
const [enabled, setEnabled] = useState(initialValues.enabled);
const [period, setPeriod] = useState<ChatUsageLimitPeriod>(
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<Group | null>(null);
const [groupAmount, setGroupAmount] = useState("");
const [showUserForm, setShowUserForm] = useState(false);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [userOverrideAmount, setUserOverrideAmount] = useState("");
const defaultLimitValues = useMemo<DefaultLimitFormValues>(() => {
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 (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="flex flex-1 items-center justify-center px-6 py-5">
<Spinner loading className="h-6 w-6" />
</div>
</div>
);
}
if (configQuery.isError) {
return (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="flex flex-1 items-center justify-center px-6 py-5">
<div className="space-y-4 py-4 text-center">
<p className="text-sm text-content-secondary">
{getErrorMessage(
configQuery.error,
"Failed to load spend limit settings.",
)}
</p>
<Button
variant="outline"
size="sm"
type="button"
onClick={() => void configQuery.refetch()}
>
Retry
</Button>
</div>
</div>
</div>
);
}
const groupOverrides = configQuery.data?.group_overrides ?? [];
const overrides = configQuery.data?.overrides ?? [];
const unpricedModelCount = configQuery.data?.unpriced_model_count ?? 0;
return (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<DefaultLimitController
key={defaultLimitKey}
initialValues={defaultLimitValues}
onSave={handleSaveDefault}
>
{({
enabled,
onEnabledChange,
period,
onPeriodChange,
amountDollars,
onAmountDollarsChange,
isAmountValid,
saveDefault,
}) => (
<>
<div className="flex-1 overflow-y-auto px-6 py-5 pb-24 [scrollbar-width:thin] [scrollbar-color:hsl(var(--surface-quaternary))_transparent]">
<div className="space-y-6">
<DefaultLimitSection
adminBadge={<AdminBadge />}
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}
/>
<GroupLimitsSection
groupOverrides={groupOverrides}
panelClassName={sectionPanelClassName}
showGroupForm={showGroupForm}
onShowGroupFormChange={setShowGroupForm}
selectedGroup={selectedGroup}
onSelectedGroupChange={setSelectedGroup}
groupAmount={groupAmount}
onGroupAmountChange={setGroupAmount}
availableGroups={availableGroups}
groupAutocompleteNoOptionsText={
groupAutocompleteNoOptionsText
}
groupsLoading={groupsQuery.isLoading}
onAddGroupOverride={handleAddGroupOverride}
onDeleteGroupOverride={handleDeleteGroupOverride}
upsertPending={upsertGroupOverrideMutation.isPending}
upsertError={
upsertGroupOverrideMutation.isError
? upsertGroupOverrideMutation.error
: null
}
deletePending={deleteGroupOverrideMutation.isPending}
deleteError={
deleteGroupOverrideMutation.isError
? deleteGroupOverrideMutation.error
: null
}
groupsError={groupsQuery.isError ? groupsQuery.error : null}
/>
<UserOverridesSection
overrides={overrides}
panelClassName={sectionPanelClassName}
showUserForm={showUserForm}
onShowUserFormChange={setShowUserForm}
selectedUser={selectedUser}
onSelectedUserChange={setSelectedUser}
userOverrideAmount={userOverrideAmount}
onUserOverrideAmountChange={setUserOverrideAmount}
selectedUserAlreadyOverridden={selectedUserAlreadyOverridden}
onAddOverride={handleAddOverride}
onDeleteOverride={handleDeleteOverride}
upsertPending={upsertOverrideMutation.isPending}
upsertError={
upsertOverrideMutation.isError
? upsertOverrideMutation.error
: null
}
deletePending={deleteOverrideMutation.isPending}
deleteError={
deleteOverrideMutation.isError
? deleteOverrideMutation.error
: null
}
/>
</div>
</div>
<div className="sticky bottom-0 flex shrink-0 flex-col gap-2 border-t border-border bg-surface-primary px-6 py-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-h-4 text-xs">
{updateConfigMutation.isError && (
<p className="m-0 text-content-destructive">
{getErrorMessage(
updateConfigMutation.error,
"Failed to save the default spend limit.",
)}
</p>
)}
{updateConfigMutation.isSuccess && (
<p className="m-0 text-content-success">Saved!</p>
)}
</div>
<Button
size="sm"
type="button"
onClick={() => void saveDefault()}
disabled={updateConfigMutation.isPending || !isAmountValid}
>
{updateConfigMutation.isPending ? (
<Spinner loading className="h-4 w-4" />
) : null}
Save default limit
</Button>
</div>
</>
)}
</DefaultLimitController>
</div>
);
};
@@ -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<UserOverridesSectionProps> = ({
overrides,
panelClassName,
showUserForm,
onShowUserFormChange,
selectedUser,
onSelectedUserChange,
userOverrideAmount,
onUserOverrideAmountChange,
selectedUserAlreadyOverridden,
onAddOverride,
onDeleteOverride,
upsertPending,
upsertError,
deletePending,
deleteError,
}) => {
const userOverrideAmountId = useId();
return (
<section className="space-y-4">
<SectionHeader
label="Per-User Overrides"
description="Override the deployment default spend limit for specific users. User overrides take highest priority, followed by group limits, then the deployment default."
/>
<div className={panelClassName}>
{overrides.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Spend Limit</TableHead>
<TableHead className="w-[80px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{overrides.map((override) => (
<TableRow key={override.user_id}>
<TableCell>
<AvatarData
title={override.name || override.username}
subtitle={`@${override.username}`}
src={override.avatar_url}
imgFallbackText={override.username}
/>
</TableCell>
<TableCell>
{override.spend_limit_micros !== null
? formatCostMicros(override.spend_limit_micros)
: "Unlimited"}
</TableCell>
<TableCell>
<Button
variant="outline"
size="sm"
type="button"
onClick={() => void onDeleteOverride(override.user_id)}
disabled={deletePending}
>
Delete
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className="rounded-lg border border-border bg-surface-secondary px-4 py-6 text-center text-sm text-content-secondary">
No overrides configured.
</div>
)}
{deleteError && (
<p className="text-xs text-content-destructive">
{getErrorMessage(deleteError, "Failed to delete override.")}
</p>
)}
{!showUserForm ? (
<Button
variant="outline"
size="sm"
type="button"
onClick={() => onShowUserFormChange(true)}
>
Add User
</Button>
) : (
<div className="space-y-3 rounded-lg border border-border bg-surface-secondary/40 p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-end">
<div className="flex-1">
<UserAutocomplete
value={selectedUser}
onChange={onSelectedUserChange}
label="User"
/>
</div>
<div className="flex-1 space-y-1">
<Label htmlFor={userOverrideAmountId}>Spend Limit ($)</Label>
<Input
id={userOverrideAmountId}
type="number"
step="0.01"
min="0"
className="h-9 min-w-0 text-[13px]"
value={userOverrideAmount}
onChange={(event) =>
onUserOverrideAmountChange(event.target.value)
}
placeholder="0.00"
/>
</div>
<div className="flex gap-2 md:pb-0.5">
<Button
size="sm"
type="button"
onClick={() => void onAddOverride()}
disabled={
upsertPending ||
!selectedUser ||
selectedUserAlreadyOverridden ||
!isPositiveFiniteDollarAmount(userOverrideAmount)
}
>
{upsertPending ? (
<Spinner loading className="h-4 w-4" />
) : null}
Add
</Button>
<Button
variant="outline"
size="sm"
type="button"
onClick={() => {
onShowUserFormChange(false);
onSelectedUserChange(null);
onUserOverrideAmountChange("");
}}
>
Cancel
</Button>
</div>
</div>
</div>
)}
{selectedUserAlreadyOverridden && (
<p className="text-xs text-content-warning">
This user already has an override.
</p>
)}
{upsertError && (
<p className="text-xs text-content-destructive">
{getErrorMessage(upsertError, "Failed to save the override.")}
</p>
)}
</div>
</section>
);
};
@@ -0,0 +1 @@
export { LimitsTab } from "./LimitsTab";
@@ -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");
});
});
});
@@ -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";
}
};
@@ -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);
});
});
@@ -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<string, unknown>;
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}.`;
}
+1 -39
View File
@@ -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", () => {
-19
View File
@@ -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"
+98
View File
@@ -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");
});
});
+53
View File
@@ -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));
}