mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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: () => ({}),
|
||||
},
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,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", () => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user