feat(ui): surface pinned premium quota alerts in chat thread

This commit is contained in:
Anish Sarkar 2026-04-29 19:15:46 +05:30
parent 835bd9f65d
commit d5ef0d2598
3 changed files with 148 additions and 10 deletions

View file

@ -19,6 +19,7 @@ import {
currentThreadAtom, currentThreadAtom,
setTargetCommentIdAtom, setTargetCommentIdAtom,
} from "@/atoms/chat/current-thread.atom"; } from "@/atoms/chat/current-thread.atom";
import { setPremiumAlertForThreadAtom } from "@/atoms/chat/premium-alert.atom";
import { import {
type MentionedDocumentInfo, type MentionedDocumentInfo,
mentionedDocumentIdsAtom, mentionedDocumentIdsAtom,
@ -200,6 +201,19 @@ const BASE_TOOLS_WITH_UI = new Set([
// "write_todos", // Disabled for now // "write_todos", // Disabled for now
]); ]);
const PINNED_PREMIUM_QUOTA_MESSAGE = "Premium token quota exceeded for this pinned model.";
function getPinnedPremiumQuotaErrorMessage(error: unknown): string | null {
if (!(error instanceof Error)) return null;
if (!error.message.toLowerCase().includes("premium token quota exceeded")) {
return null;
}
if (!error.message.toLowerCase().includes("pinned model")) {
return null;
}
return error.message || PINNED_PREMIUM_QUOTA_MESSAGE;
}
export default function NewChatPage() { export default function NewChatPage() {
const params = useParams(); const params = useParams();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@ -226,6 +240,7 @@ export default function NewChatPage() {
const setMentionedDocuments = useSetAtom(mentionedDocumentsAtom); const setMentionedDocuments = useSetAtom(mentionedDocumentsAtom);
const setMessageDocumentsMap = useSetAtom(messageDocumentsMapAtom); const setMessageDocumentsMap = useSetAtom(messageDocumentsMapAtom);
const setCurrentThreadState = useSetAtom(currentThreadAtom); const setCurrentThreadState = useSetAtom(currentThreadAtom);
const setPremiumAlertForThread = useSetAtom(setPremiumAlertForThreadAtom);
const setTargetCommentId = useSetAtom(setTargetCommentIdAtom); const setTargetCommentId = useSetAtom(setTargetCommentIdAtom);
const clearTargetCommentId = useSetAtom(clearTargetCommentIdAtom); const clearTargetCommentId = useSetAtom(clearTargetCommentIdAtom);
const closeReportPanel = useSetAtom(closeReportPanelAtom); const closeReportPanel = useSetAtom(closeReportPanelAtom);
@ -951,6 +966,7 @@ export default function NewChatPage() {
return; return;
} }
console.error("[NewChatPage] Chat error:", error); console.error("[NewChatPage] Chat error:", error);
const premiumQuotaAlertMessage = getPinnedPremiumQuotaErrorMessage(error);
// Track chat error // Track chat error
trackChatError( trackChatError(
@ -959,7 +975,15 @@ export default function NewChatPage() {
error instanceof Error ? error.message : "Unknown error" error instanceof Error ? error.message : "Unknown error"
); );
if (premiumQuotaAlertMessage) {
setPremiumAlertForThread({
threadId: currentThreadId,
message: premiumQuotaAlertMessage,
});
toast.error(PINNED_PREMIUM_QUOTA_MESSAGE);
} else {
toast.error("Failed to get response. Please try again."); toast.error("Failed to get response. Please try again.");
}
// Update assistant message with error // Update assistant message with error
setMessages((prev) => setMessages((prev) =>
prev.map((m) => prev.map((m) =>
@ -969,7 +993,9 @@ export default function NewChatPage() {
content: [ content: [
{ {
type: "text", type: "text",
text: "Sorry, there was an error. Please try again.", text:
premiumQuotaAlertMessage ??
"Sorry, there was an error. Please try again.",
}, },
], ],
} }
@ -998,6 +1024,7 @@ export default function NewChatPage() {
pendingUserImageUrls, pendingUserImageUrls,
setPendingUserImageUrls, setPendingUserImageUrls,
toolsWithUI, toolsWithUI,
setPremiumAlertForThread,
] ]
); );
@ -1257,13 +1284,29 @@ export default function NewChatPage() {
return; return;
} }
console.error("[NewChatPage] Resume error:", error); console.error("[NewChatPage] Resume error:", error);
const premiumQuotaAlertMessage = getPinnedPremiumQuotaErrorMessage(error);
if (premiumQuotaAlertMessage) {
setPremiumAlertForThread({
threadId: resumeThreadId,
message: premiumQuotaAlertMessage,
});
toast.error(PINNED_PREMIUM_QUOTA_MESSAGE);
} else {
toast.error("Failed to resume. Please try again."); toast.error("Failed to resume. Please try again.");
}
} finally { } finally {
setIsRunning(false); setIsRunning(false);
abortControllerRef.current = null; abortControllerRef.current = null;
} }
}, },
[pendingInterrupt, messages, searchSpaceId, tokenUsageStore, toolsWithUI] [
pendingInterrupt,
messages,
searchSpaceId,
tokenUsageStore,
toolsWithUI,
setPremiumAlertForThread,
]
); );
useEffect(() => { useEffect(() => {
@ -1584,18 +1627,34 @@ export default function NewChatPage() {
} }
batcher.dispose(); batcher.dispose();
console.error("[NewChatPage] Regeneration error:", error); console.error("[NewChatPage] Regeneration error:", error);
const premiumQuotaAlertMessage = getPinnedPremiumQuotaErrorMessage(error);
trackChatError( trackChatError(
searchSpaceId, searchSpaceId,
threadId, threadId,
error instanceof Error ? error.message : "Unknown error" error instanceof Error ? error.message : "Unknown error"
); );
if (premiumQuotaAlertMessage) {
setPremiumAlertForThread({
threadId,
message: premiumQuotaAlertMessage,
});
toast.error(PINNED_PREMIUM_QUOTA_MESSAGE);
} else {
toast.error("Failed to regenerate response. Please try again."); toast.error("Failed to regenerate response. Please try again.");
}
setMessages((prev) => setMessages((prev) =>
prev.map((m) => prev.map((m) =>
m.id === assistantMsgId m.id === assistantMsgId
? { ? {
...m, ...m,
content: [{ type: "text", text: "Sorry, there was an error. Please try again." }], content: [
{
type: "text",
text:
premiumQuotaAlertMessage ??
"Sorry, there was an error. Please try again.",
},
],
} }
: m : m
) )
@ -1605,7 +1664,15 @@ export default function NewChatPage() {
abortControllerRef.current = null; abortControllerRef.current = null;
} }
}, },
[threadId, searchSpaceId, messages, disabledTools, tokenUsageStore, toolsWithUI] [
threadId,
searchSpaceId,
messages,
disabledTools,
tokenUsageStore,
toolsWithUI,
setPremiumAlertForThread,
]
); );
// Handle editing a message - truncates history and regenerates with new query // Handle editing a message - truncates history and regenerates with new query

View file

@ -0,0 +1,33 @@
import { atom } from "jotai";
export type PremiumAlertState = {
message: string;
};
export const premiumAlertByThreadAtom = atom<Record<number, PremiumAlertState>>({});
export const setPremiumAlertForThreadAtom = atom(
null,
(
get,
set,
payload: {
threadId: number;
message: string;
}
) => {
const current = get(premiumAlertByThreadAtom);
set(premiumAlertByThreadAtom, {
...current,
[payload.threadId]: { message: payload.message },
});
}
);
export const clearPremiumAlertForThreadAtom = atom(null, (get, set, threadId: number) => {
const current = get(premiumAlertByThreadAtom);
if (!(threadId in current)) return;
const next = { ...current };
delete next[threadId];
set(premiumAlertByThreadAtom, next);
});

View file

@ -37,10 +37,13 @@ import {
toggleToolAtom, toggleToolAtom,
} from "@/atoms/agent-tools/agent-tools.atoms"; } from "@/atoms/agent-tools/agent-tools.atoms";
import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom"; import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom";
import { import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
mentionedDocumentsAtom, import { mentionedDocumentsAtom } from "@/atoms/chat/mentioned-documents.atom";
} from "@/atoms/chat/mentioned-documents.atom";
import { pendingUserImageDataUrlsAtom } from "@/atoms/chat/pending-user-images.atom"; import { pendingUserImageDataUrlsAtom } from "@/atoms/chat/pending-user-images.atom";
import {
clearPremiumAlertForThreadAtom,
premiumAlertByThreadAtom,
} from "@/atoms/chat/premium-alert.atom";
import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms"; import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms"; import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
import { membersAtom } from "@/atoms/members/members-query.atoms"; import { membersAtom } from "@/atoms/members/members-query.atoms";
@ -134,6 +137,9 @@ const ThreadContent: FC = () => {
style={{ paddingBottom: "max(1rem, env(safe-area-inset-bottom))" }} style={{ paddingBottom: "max(1rem, env(safe-area-inset-bottom))" }}
> >
<ThreadScrollToBottom /> <ThreadScrollToBottom />
<AuiIf condition={({ thread }) => !thread.isEmpty}>
<PremiumQuotaPinnedAlert />
</AuiIf>
<AuiIf condition={({ thread }) => !thread.isEmpty}> <AuiIf condition={({ thread }) => !thread.isEmpty}>
<Composer /> <Composer />
</AuiIf> </AuiIf>
@ -143,6 +149,38 @@ const ThreadContent: FC = () => {
); );
}; };
const PremiumQuotaPinnedAlert: FC = () => {
const currentThreadState = useAtomValue(currentThreadAtom);
const alertsByThread = useAtomValue(premiumAlertByThreadAtom);
const clearPremiumAlertForThread = useSetAtom(clearPremiumAlertForThreadAtom);
const currentThreadId = currentThreadState?.id;
if (!currentThreadId) return null;
const alert = alertsByThread[currentThreadId];
if (!alert) return null;
return (
<div className="mx-2 rounded-2xl border border-amber-300/40 bg-amber-500/10 px-4 py-3 text-amber-50 shadow-lg backdrop-blur-sm">
<div className="flex items-start gap-2">
<AlertCircle className="mt-0.5 size-4 shrink-0 text-amber-300" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">Premium quota exhausted</p>
<p className="mt-1 text-xs text-amber-100/90">{alert.message}</p>
</div>
<button
type="button"
className="inline-flex size-6 items-center justify-center rounded-md text-amber-200 transition-colors hover:bg-amber-200/20 hover:text-amber-50"
aria-label="Dismiss premium quota alert"
onClick={() => clearPremiumAlertForThread(currentThreadId)}
>
<X className="size-4" />
</button>
</div>
</div>
);
};
const ThreadScrollToBottom: FC = () => { const ThreadScrollToBottom: FC = () => {
return ( return (
<ThreadPrimitive.ScrollToBottom asChild> <ThreadPrimitive.ScrollToBottom asChild>