diff --git a/frontend/public/maple-logo-dark.svg b/frontend/public/maple-logo-dark.svg index 7fe0aba8..bb539706 100644 --- a/frontend/public/maple-logo-dark.svg +++ b/frontend/public/maple-logo-dark.svg @@ -1,9 +1,11 @@ + + diff --git a/frontend/public/maple-logo.svg b/frontend/public/maple-logo.svg index cb7c342c..03a690dd 100644 --- a/frontend/public/maple-logo.svg +++ b/frontend/public/maple-logo.svg @@ -1,7 +1,9 @@ + + diff --git a/frontend/src/chat.css b/frontend/src/chat.css index 551d1304..7c99fbbd 100644 --- a/frontend/src/chat.css +++ b/frontend/src/chat.css @@ -4,8 +4,9 @@ margin: 0; color: var(--color-fg-default); background-color: var(--color-canvas-default); - font-size: 14px; - line-height: 1.5; + font-size: 15px; + line-height: 1.65; + letter-spacing: 0.1px; word-wrap: break-word; } @@ -341,8 +342,9 @@ .markdown-body p { margin-top: 0; margin-bottom: 10px; - font-size: 14px; - line-height: 1.5; + font-size: 15px; + line-height: 1.65; + letter-spacing: 0.1px; } .markdown-body blockquote { @@ -357,8 +359,9 @@ margin-top: 0; margin-bottom: 0; padding-left: 2em; - font-size: 14px; - line-height: 1.5; + font-size: 15px; + line-height: 1.65; + letter-spacing: 0.1px; } .markdown-body ul { @@ -608,13 +611,15 @@ .markdown-body li > p { margin-top: 16px; - font-size: 14px; - line-height: 1.5; + font-size: 15px; + line-height: 1.65; + letter-spacing: 0.1px; } .markdown-body li { - font-size: 14px; - line-height: 1.5; + font-size: 15px; + line-height: 1.65; + letter-spacing: 0.1px; } .markdown-body li + li { @@ -651,8 +656,9 @@ word-wrap: break-word; overflow-wrap: break-word; white-space: normal; - font-size: 14px; - line-height: 1.5; + font-size: 15px; + line-height: 1.65; + letter-spacing: 0.1px; } .markdown-body table tr { diff --git a/frontend/src/components/AccountMenu.tsx b/frontend/src/components/AccountMenu.tsx index cb801acd..2d4c9c4d 100644 --- a/frontend/src/components/AccountMenu.tsx +++ b/frontend/src/components/AccountMenu.tsx @@ -29,7 +29,7 @@ import { useNavigate, useRouter } from "@tanstack/react-router"; import { Dialog, DialogTrigger } from "./ui/dialog"; import { AccountDialog } from "./AccountDialog"; import { CreditUsage } from "./CreditUsage"; -import { Badge } from "./ui/badge"; +import { Badge } from "@/components/ui/badge"; import { AlertDialog, @@ -52,6 +52,7 @@ import { Alert, AlertDescription } from "@/components/ui/alert"; import { TeamManagementDialog } from "@/components/team/TeamManagementDialog"; import { ApiKeyManagementDialog } from "@/components/apikeys/ApiKeyManagementDialog"; import packageJson from "../../package.json"; +import { SIDEBAR_ACCOUNT_MENU_WIDTH_CLASS, SIDEBAR_LAYOUT_STYLE } from "@/constants/layout"; function ConfirmDeleteDialog() { const { clearHistory } = useLocalState(); @@ -291,23 +292,8 @@ export function AccountMenu() { !open && setShowAboutMenu(false)}> -
-
-
- - - {billingStatus ? `${billingStatus.product_name} Plan` : "Loading..."} - - -
-
- -
-
-
+
+
+ + +
{/* align=start: panel aligns to sidebar content edge; center was relative to the small icon */} { + const renderConversationRow = (conversation: Conversation) => { const title = getConversationTitle(conversation); const isActive = conversation.id === currentChatId; const isSelected = selectedIds.has(conversation.id); - const fadeClass = isMobile - ? SIDEBAR_TITLE_FADE_MOBILE - : isActive - ? SIDEBAR_TITLE_FADE_DESKTOP_ACTIVE - : SIDEBAR_TITLE_FADE_DESKTOP; - const titlePaddingClass = isMobile - ? "pr-8" - : "pr-2 transition-[padding] duration-150 ease-out group-hover:pr-8"; + const titlePaddingClass = "pr-8"; + + const isBoldState = (isActive && !isSelectionMode) || (isSelectionMode && isSelected); + const rowTextClass = isBoldState + ? "font-bold text-foreground" + : "text-foreground/95 group-hover:text-foreground"; return (
event.preventDefault()} >
- {isSelectionMode ? ( -
- toggleSelection(conversation.id)} - onClick={(event) => event.stopPropagation()} - className="data-[state=checked]:bg-primary" - /> -
- ) : null}
-
+
{conversation.pinned ? ( - + ) : null} -
- {title} -
-
-
-
-
+
{title}
{new Date(conversation.last_activity_at * 1000).toLocaleDateString()}
+ {isSelectionMode ? ( +
e.stopPropagation()} + > + toggleSelection(conversation.id)} + onClick={(event) => event.stopPropagation()} + className="data-[state=checked]:bg-primary" + /> +
+ ) : null} {!isSelectionMode ? ( <> - - - - - - onSelectionChange(new Set([conversation.id]))}> - - Select - - handleToggleConversationPin(conversation)}> - {conversation.pinned ? ( - - ) : ( - - )} - {conversation.pinned ? "Unpin Chat" : "Pin Chat"} - - - - - Move to Project - - - handleMoveConversationToProject(conversation, null)} +
+ ) : null}
@@ -1303,10 +1309,15 @@ export function ChatHistoryList({
-
+
Projects @@ -1319,7 +1330,7 @@ export function ChatHistoryList({ disabled className="flex w-full items-center gap-2 rounded-2xl py-1.5 pl-0 pr-1 text-left text-muted-foreground/50 cursor-not-allowed" > - + New project @@ -1331,9 +1342,9 @@ export function ChatHistoryList({ )} @@ -1343,62 +1354,64 @@ export function ChatHistoryList({ const isProjectSelected = selectedProjectId === project.id; return (
-
+
- - - - - - void handleViewProject(project.id)}> - - View Project - - - handleOpenRenameProjectDialog(project)}> - - Rename Project - - handleOpenDeleteProjectDialog(project)}> - - Delete Project - - - +
+
{isProjectExpanded && filteredExpandedProjectConversations.length > 0 ? ( -
+
{filteredExpandedProjectConversations.map((conversation) => - renderConversationRow(conversation, { compact: true }) + renderConversationRow(conversation) )}
) : null} @@ -1409,9 +1422,8 @@ export function ChatHistoryList({ {filteredPinnedConversations.length > 0 ? (
-
- - Pinned +
+ Pinned
{filteredPinnedConversations.map((conversation) => renderConversationRow(conversation))}
@@ -1445,9 +1457,9 @@ export function ChatHistoryList({ className="mb-2 flex w-full items-center gap-2 text-xs font-medium uppercase tracking-wider text-muted-foreground transition-colors hover:text-foreground" > {isArchivedExpanded ? ( - + ) : ( - + )} Archived ({filteredArchivedChats.length}) @@ -1456,68 +1468,66 @@ export function ChatHistoryList({
{filteredArchivedChats.map((chat) => { const isActive = chat.id === currentChatId; - const archivedFadeClass = isMobile - ? SIDEBAR_TITLE_FADE_MOBILE - : isActive - ? SIDEBAR_TITLE_FADE_DESKTOP_ACTIVE - : SIDEBAR_TITLE_FADE_DESKTOP; - const archivedTitlePaddingClass = isMobile - ? "pr-8" - : "pr-2 transition-[padding] duration-150 ease-out group-hover:pr-8"; + const archivedTitlePaddingClass = "pr-8"; return (
{ 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" }`} > -
-
+
+
{chat.title}
-
-
-
-
{new Date(chat.updated_at || chat.created_at).toLocaleDateString()}
- - - - - - 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 ( - {WORD_PATHS.map((d, i) => ( - - ))} + + {WORD_PATHS.map((d, i) => ( + + ))} + ); } 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({
- + {hasMore && (