{
setSelectedProjectId(null);
router.navigate({ to: "/chat/$chatId", params: { chatId: chat.id } });
}}
- className={`relative min-w-0 flex-1 cursor-pointer rounded-2xl py-1 transition-all hover:text-primary ${
- isActive ? "text-primary" : "text-muted-foreground"
+ className={`relative ${ROW_CONTENT_Z} min-w-0 flex-1 cursor-pointer py-1 pl-0 pr-2 ${
+ isActive
+ ? "font-bold text-foreground"
+ : "text-foreground/95 group-hover:text-foreground"
}`}
>
-
-
+
{new Date(chat.updated_at || chat.created_at).toLocaleDateString()}
-
-
-
-
-
- handleOpenRenameDialogArchived(chat)}>
-
- Rename Chat
-
- handleOpenDeleteDialogArchived(chat)}>
-
- Delete Chat
-
-
-
+
+
+
+
+
+
+
+
+ handleOpenRenameDialogArchived(chat)}
+ >
+
+ Rename Chat
+
+ handleOpenDeleteDialogArchived(chat)}
+ >
+
+ Delete Chat
+
+
+
+
+
);
})}
diff --git a/frontend/src/components/CreditUsage.tsx b/frontend/src/components/CreditUsage.tsx
index b32ee2aa..0a8f4dc0 100644
--- a/frontend/src/components/CreditUsage.tsx
+++ b/frontend/src/components/CreditUsage.tsx
@@ -1,250 +1,132 @@
-import { useId } from "react";
-
import { useLocalState } from "@/state/useLocalState";
import { formatResetDate } from "@/utils/dateFormat";
-import { cn } from "@/utils/utils";
-
-type CreditUsageLayout = "bar" | "ring";
-
-/** Dev-only opt-in: localStorage `maple_mock_credit_scenario` = demo | full | high | warn | ok | off */
-type MockScenario = "demo" | "full" | "high" | "warn" | "ok";
-
-function readMockScenario(): MockScenario | "off" | null {
- if (!import.meta.env.DEV || typeof window === "undefined") return null;
- let raw: string | null = null;
- try {
- raw = localStorage.getItem("maple_mock_credit_scenario");
- } catch {
- return null;
- }
- if (raw === "off") return "off";
- if (raw === "demo" || raw === "full" || raw === "high" || raw === "warn" || raw === "ok")
- return raw;
- return null;
-}
-function mockPreset(scenario: MockScenario): {
- total_tokens: number;
- used_tokens: number;
- api_credit_balance?: number;
-} {
- const t = 10_000;
- switch (scenario) {
- case "demo":
- return { total_tokens: t, used_tokens: 6900 };
- case "full":
- return { total_tokens: t, used_tokens: t, api_credit_balance: 2_500 };
- case "high":
- return { total_tokens: t, used_tokens: 9_650 };
- case "warn":
- return { total_tokens: t, used_tokens: 7_800 };
- case "ok":
- return { total_tokens: t, used_tokens: 3_200, api_credit_balance: 12_000 };
- default:
- return { total_tokens: t, used_tokens: 6900 };
- }
+function toPlanNameLabel(rawPlanName: string | undefined): string {
+ if (!rawPlanName?.trim()) return "Loading...";
+ const cleaned = (rawPlanName ?? "Pro").trim();
+ const hasPlanSuffix = /\bplan\b/i.test(cleaned);
+ return hasPlanSuffix ? cleaned : `${cleaned} Plan`;
}
-/** ~14 days out so formatResetDate shows a friendly string */
-function mockUsageResetIso(): string {
- const d = new Date();
- d.setDate(d.getDate() + 14);
- return d.toISOString();
-}
-
-function usageTone(percentUsed: number): "danger" | "warn" | "ok" {
- if (percentUsed >= 90) return "danger";
- if (percentUsed >= 75) return "warn";
- return "ok";
-}
-
-function toneColor(tone: ReturnType
): string {
- switch (tone) {
- case "danger":
- return "hsl(var(--maple-error))";
- case "warn":
- return "hsl(var(--maple-warning))";
- default:
- return "hsl(var(--maple-success))";
- }
-}
-
-function toneTextClass(tone: ReturnType): string {
- switch (tone) {
- case "danger":
- return "text-maple-error";
- case "warn":
- return "text-maple-warning";
- default:
- return "text-maple-success";
- }
-}
-
-type RingMeterProps = {
- percent: number;
- size?: number;
- stroke?: number;
+type CreditUsageViewProps = {
+ planLabel: string;
+ percentUsed?: number;
+ roundedUsed?: number;
+ total?: number;
+ used?: number;
+ tokensRemaining?: number;
+ apiBalance?: number;
+ hasApiCredits: boolean;
+ resetFullLabel?: string;
+ formatCredits: (n: number) => string;
};
-function RingMeter({ percent, size = 32, stroke = 3.5 }: RingMeterProps) {
- const gradId = `credit-ring-grad-${useId().replace(/:/g, "")}`;
- const r = (size - stroke) / 2;
- const c = 2 * Math.PI * r;
- const clamped = Math.min(100, Math.max(0, percent));
- const offset = c - (clamped / 100) * c;
+function CreditUsageView(p: CreditUsageViewProps) {
+ const hasUsageMeter =
+ p.percentUsed !== undefined &&
+ p.roundedUsed !== undefined &&
+ p.total !== undefined &&
+ p.used !== undefined &&
+ p.tokensRemaining !== undefined;
- return (
-
- );
-}
-
-function UsageRing({
- percentUsed,
- roundedPercent,
- size = 32,
- stroke = 3.5
-}: {
- percentUsed: number;
- roundedPercent: number;
- size?: number;
- stroke?: number;
-}) {
return (
-
+
+
+ {p.planLabel}
+
+ {hasUsageMeter ? (
+ <>
+ ·
+
+ {p.roundedUsed}% used
+
+ {p.resetFullLabel && (
+ <>
+ ·
+
+ {p.resetFullLabel}
+
+ >
+ )}
+ >
+ ) : null}
+
+ {hasUsageMeter ? (
+
+
+
+
+
+
+ {p.formatCredits(p.tokensRemaining!)} left of {p.formatCredits(p.total!)} tokens
+ {p.hasApiCredits && (
+
+ +{p.formatCredits(p.apiBalance ?? 0)}
+
+ )}
+
+
+
+
+
+ ) : null}
);
}
-export function CreditUsage({ layout = "bar" }: { layout?: CreditUsageLayout }) {
+export function CreditUsage() {
const { billingStatus } = useLocalState();
const totalLive = billingStatus?.total_tokens;
const usedLive = billingStatus?.used_tokens;
- const hasRealUsage = totalLive != null && totalLive > 0 && usedLive != null && usedLive > 0;
-
- const mockFlag = readMockScenario();
- const useMock = !hasRealUsage && import.meta.env.DEV && mockFlag !== null && mockFlag !== "off";
+ const hasUsageData = totalLive != null && totalLive > 0 && usedLive != null;
+ const productName = billingStatus?.product_name;
+ const apiBalance = billingStatus?.api_credit_balance;
- if (!hasRealUsage && !useMock) {
- return null;
- }
-
- const mock = useMock ? mockPreset(mockFlag as MockScenario) : null;
- const total = hasRealUsage ? totalLive! : mock!.total_tokens;
- const used = hasRealUsage ? usedLive! : mock!.used_tokens;
- const productName = hasRealUsage ? billingStatus?.product_name : "Pro";
- const usageResetDate = hasRealUsage ? billingStatus!.usage_reset_date : mockUsageResetIso();
- const apiBalance = hasRealUsage ? billingStatus!.api_credit_balance : mock?.api_credit_balance;
-
- const percentUsed = Math.min(100, Math.max(0, (used / total) * 100));
- const roundedPercent = Math.round(percentUsed);
- const tone = usageTone(percentUsed);
- const barColor = toneColor(tone);
+ const used = hasUsageData ? Math.max(0, usedLive!) : undefined;
+ const percentUsed = hasUsageData ? Math.min(100, Math.max(0, (used! / totalLive!) * 100)) : 0;
const isMaxPlan = productName?.toLowerCase().includes("max") ?? false;
- if (isMaxPlan && percentUsed < 90) {
- return null;
- }
+ const shouldShowUsageMeter = hasUsageData && (!isMaxPlan || percentUsed >= 90);
const hasApiCredits = apiBalance !== undefined && apiBalance > 0;
- const formatCredits = (credits: number) => {
- return new Intl.NumberFormat("en-US").format(credits);
+ const formatCredits = (credits: number) => new Intl.NumberFormat("en-US").format(credits);
+
+ const planLabel = toPlanNameLabel(productName);
+ const resetFullLabel = shouldShowUsageMeter
+ ? formatResetDate(billingStatus?.usage_reset_date)
+ : undefined;
+
+ const props: CreditUsageViewProps = {
+ planLabel,
+ ...(shouldShowUsageMeter
+ ? {
+ percentUsed,
+ roundedUsed: Math.round(percentUsed),
+ total: totalLive!,
+ used: used!,
+ tokensRemaining: Math.max(0, totalLive! - used!)
+ }
+ : {}),
+ apiBalance,
+ hasApiCredits,
+ resetFullLabel,
+ formatCredits
};
- const statusLabel =
- percentUsed >= 100 ? "Limit reached" : percentUsed >= 90 ? "Almost full" : "Plan credits";
-
- if (layout === "ring") {
- return (
-
-
-
-
-
- {statusLabel}
-
-
-
- {hasApiCredits && (
- <>
- +{formatCredits(apiBalance ?? 0)} extra
- ·
- >
- )}
- {formatResetDate(usageResetDate)}
-
-
-
-
-
- );
- }
-
- return (
-
-
- {percentUsed >= 100 ? "Plan credits (full)" : "Plan Credits"}
- {roundedPercent}%
-
-
-
- {hasApiCredits && + {formatCredits(apiBalance ?? 0)} extra credits}
- {formatResetDate(usageResetDate)}
-
-
- );
+ return ;
}
diff --git a/frontend/src/components/MapleWordmark.tsx b/frontend/src/components/MapleWordmark.tsx
index 4aa716a1..0d4b1fd7 100644
--- a/frontend/src/components/MapleWordmark.tsx
+++ b/frontend/src/components/MapleWordmark.tsx
@@ -19,17 +19,19 @@ export function MapleWordmark({
}) {
return (
);
}
diff --git a/frontend/src/components/ProjectDetailView.tsx b/frontend/src/components/ProjectDetailView.tsx
index db808eaa..2b91eca4 100644
--- a/frontend/src/components/ProjectDetailView.tsx
+++ b/frontend/src/components/ProjectDetailView.tsx
@@ -46,6 +46,7 @@ import { DeleteChatDialog } from "@/components/DeleteChatDialog";
import { BulkDeleteDialog } from "@/components/BulkDeleteDialog";
import { MoveChatsDialog } from "@/components/MoveChatsDialog";
import { listAllConversationProjects } from "@/utils/paginatedLists";
+import { SIDEBAR_GRID_COLUMNS_CLASS, SIDEBAR_LAYOUT_STYLE } from "@/constants/layout";
const PROJECT_PAGE_SIZE = 20;
const MAX_SELECTION = 20;
@@ -448,8 +449,9 @@ export function ProjectDetailView({ projectId }: ProjectDetailViewProps) {
if (isProjectPending && !project) {
return (
@@ -462,8 +464,9 @@ export function ProjectDetailView({ projectId }: ProjectDetailViewProps) {
return (
@@ -585,7 +588,7 @@ export function ProjectDetailView({ projectId }: ProjectDetailViewProps) {
return (
@@ -640,7 +643,7 @@ export function ProjectDetailView({ projectId }: ProjectDetailViewProps) {
type="button"
variant="ghost"
size="icon"
- className="absolute right-2 top-2 h-8 w-8"
+ className="absolute right-2 top-2 h-8 w-8 text-foreground/40 transition-colors hover:text-foreground group-hover:text-foreground focus-visible:text-foreground"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx
index 004d6c7b..fc768fb2 100644
--- a/frontend/src/components/Sidebar.tsx
+++ b/frontend/src/components/Sidebar.tsx
@@ -18,6 +18,11 @@ import { cn, useClickOutside, useIsMobile } from "@/utils/utils";
import { MapleWordmark } from "@/components/MapleWordmark";
import { Input } from "./ui/input";
import { useLocalState } from "@/state/useLocalState";
+import {
+ SIDEBAR_LAYOUT_STYLE,
+ SIDEBAR_MAX_WIDTH_CLASS,
+ SIDEBAR_WIDTH_CLASS
+} from "@/constants/layout";
export function Sidebar({
chatId,
@@ -191,12 +196,19 @@ export function Sidebar({
return (
-
+
{/* Header section */}
@@ -214,10 +226,11 @@ export function Sidebar({
diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx
index de8ed404..45db5f7c 100644
--- a/frontend/src/components/TopNav.tsx
+++ b/frontend/src/components/TopNav.tsx
@@ -41,9 +41,17 @@ export function TopNav() {
diff --git a/frontend/src/components/UnifiedChat.tsx b/frontend/src/components/UnifiedChat.tsx
index aa3e5e00..167dd417 100644
--- a/frontend/src/components/UnifiedChat.tsx
+++ b/frontend/src/components/UnifiedChat.tsx
@@ -73,6 +73,11 @@ import {
} from "@/components/ui/dropdown-menu";
import { isTauri } from "@/utils/platform";
import { ConversationProjectPicker } from "@/components/ConversationProjectPicker";
+import {
+ getSidebarLayoutStyle,
+ SIDEBAR_AWARE_FIXED_CENTER_CLASS,
+ SIDEBAR_GRID_COLUMNS_CLASS
+} from "@/constants/layout";
import type {
InputTextContent,
OutputTextContent,
@@ -97,6 +102,8 @@ import type {
} from "openai/resources/responses/responses.js";
import type { Message as OpenAIMessage } from "openai/resources/conversations/conversations.js";
+const CHAT_ALERT_CLASS = `fixed top-16 left-1/2 -translate-x-1/2 z-50 w-full max-w-2xl px-4 ${SIDEBAR_AWARE_FIXED_CENTER_CLASS}`;
+
type ConversationContent =
| InputTextContent
| OutputTextContent
@@ -917,7 +924,7 @@ function ToolCallRenderer({
-
+
{hasMore && (
-
+
{hasMore && (