feat: simplify mobile header and improve context usage display

This commit is contained in:
Bohdan Triapitsyn
2025-12-15 14:07:18 +02:00
parent 6b78ad0b49
commit 3b9b65a398
3 changed files with 128 additions and 111 deletions
+56 -106
View File
@@ -4,8 +4,8 @@ import {
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { Button } from '@/components/ui/button';
import { RiArrowDownSLine, RiArrowUpSLine, RiChat4Line, RiCodeLine, RiGitBranchLine, RiLayoutLeftLine, RiPlayListAddLine, RiTerminalBoxLine, type RemixiconComponentType } from '@remixicon/react';
import { RiChat4Line, RiCodeLine, RiGitBranchLine, RiLayoutLeftLine, RiPlayListAddLine, RiTerminalBoxLine, type RemixiconComponentType } from '@remixicon/react';
import { useUIStore, type MainTab } from '@/stores/useUIStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { useSessionStore } from '@/stores/useSessionStore';
@@ -121,7 +121,6 @@ export const Header: React.FC = () => {
const contextLimit = (limit && typeof limit.context === 'number' ? limit.context : 0);
const outputLimit = (limit && typeof limit.output === 'number' ? limit.output : 0);
const contextUsage = getContextUsage(contextLimit, outputLimit);
const [isMobileDetailsOpen, setIsMobileDetailsOpen] = React.useState(false);
const isSessionSwitcherOpen = useUIStore((state) => state.isSessionSwitcherOpen);
const handleOpenSessionSwitcher = React.useCallback(() => {
@@ -182,17 +181,7 @@ export const Header: React.FC = () => {
useEffect(() => {
updateHeaderHeight();
}, [updateHeaderHeight, isMobile, isMobileDetailsOpen]);
const formatTokenValue = React.useCallback((tokens: number) => {
if (tokens >= 1_000_000) {
return `${(tokens / 1_000_000).toFixed(1)}M`;
}
if (tokens >= 1_000) {
return `${(tokens / 1_000).toFixed(1)}K`;
}
return tokens.toFixed(1).replace(/\.0$/, '');
}, []);
}, [updateHeaderHeight, isMobile]);
const handleDragStart = React.useCallback(async (e: React.MouseEvent) => {
@@ -368,101 +357,62 @@ export const Header: React.FC = () => {
);
const renderMobile = () => (
<div className="app-region-drag relative flex flex-col gap-1 px-3 py-2 select-none">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<button
onClick={handleOpenSessionSwitcher}
className="app-region-no-drag h-9 w-9 p-2 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
aria-label="Open sessions"
>
<RiPlayListAddLine className="h-5 w-5" />
</button>
{contextUsage && contextUsage.totalTokens > 0 && activeMainTab === 'chat' && (
<ContextUsageDisplay
totalTokens={contextUsage.totalTokens}
percentage={contextUsage.percentage}
contextLimit={contextUsage.contextLimit}
outputLimit={contextUsage.outputLimit ?? 0}
size="compact"
/>
)}
</div>
<div className="app-region-no-drag flex items-center gap-1.5">
{}
<div className="flex items-center" role="tablist" aria-label="Main navigation">
{tabs.map((tab) => {
const isActive = activeMainTab === tab.id;
const Icon = tab.icon;
return (
<Tooltip key={tab.id} delayDuration={500}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setActiveMainTab(tab.id)}
aria-label={tab.label}
aria-selected={isActive}
role="tab"
className={cn(
headerIconButtonClass,
isActive && 'text-foreground'
)}
>
<Icon className="h-5 w-5" />
{tab.badge !== undefined && tab.badge > 0 && (
<span className="absolute -top-1 -right-1 text-[10px] font-semibold text-primary">
{tab.badge}
</span>
)}
</button>
</TooltipTrigger>
<TooltipContent>
<p>{tab.label}</p>
</TooltipContent>
</Tooltip>
);
})}
</div>
<Button
type="button"
variant="ghost"
size="icon"
aria-expanded={isMobileDetailsOpen}
aria-controls="mobile-header-details"
onClick={() => setIsMobileDetailsOpen((prev) => !prev)}
className="app-region-no-drag h-8 w-8"
>
{isMobileDetailsOpen ? <RiArrowUpSLine className="h-4 w-4" /> : <RiArrowDownSLine className="h-4 w-4" />}
</Button>
</div>
<div className="app-region-drag relative flex items-center justify-between gap-2 px-3 py-2 select-none">
<div className="flex items-center gap-2">
<button
onClick={handleOpenSessionSwitcher}
className="app-region-no-drag h-9 w-9 p-2 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
aria-label="Open sessions"
>
<RiPlayListAddLine className="h-5 w-5" />
</button>
{contextUsage && contextUsage.totalTokens > 0 && activeMainTab === 'chat' && (
<ContextUsageDisplay
totalTokens={contextUsage.totalTokens}
percentage={contextUsage.percentage}
contextLimit={contextUsage.contextLimit}
outputLimit={contextUsage.outputLimit ?? 0}
size="compact"
isMobile={true}
/>
)}
</div>
{isMobileDetailsOpen && (
<div
id="mobile-header-details"
className="app-region-no-drag absolute left-0 right-0 top-full z-40 translate-y-2 px-3"
>
<div className="flex flex-col gap-4 rounded-xl border border-border/40 bg-background/95 px-3 py-3 shadow-none">
{contextUsage && contextUsage.totalTokens > 0 && (
<div className="flex flex-col gap-1">
<span className="typography-micro text-muted-foreground">Context usage</span>
<div className="rounded-lg border border-border/40 bg-muted/10 px-3 py-2 space-y-0.5">
<p className="typography-meta">
Used tokens: <span className="font-semibold text-foreground">{formatTokenValue(contextUsage.totalTokens)}</span>
</p>
<p className="typography-meta">
Context limit: <span className="font-semibold text-foreground">{formatTokenValue(contextUsage.contextLimit)}</span>
</p>
<p className="typography-meta">
Output limit: <span className="font-semibold text-foreground">{formatTokenValue(contextUsage.outputLimit ?? 0)}</span>
</p>
</div>
</div>
)}
</div>
<div className="app-region-no-drag flex items-center gap-1.5">
<div className="flex items-center" role="tablist" aria-label="Main navigation">
{tabs.map((tab) => {
const isActive = activeMainTab === tab.id;
const Icon = tab.icon;
return (
<Tooltip key={tab.id} delayDuration={500}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setActiveMainTab(tab.id)}
aria-label={tab.label}
aria-selected={isActive}
role="tab"
className={cn(
headerIconButtonClass,
isActive && 'text-foreground'
)}
>
<Icon className="h-5 w-5" />
{tab.badge !== undefined && tab.badge > 0 && (
<span className="absolute -top-1 -right-1 text-[10px] font-semibold text-primary">
{tab.badge}
</span>
)}
</button>
</TooltipTrigger>
<TooltipContent>
<p>{tab.label}</p>
</TooltipContent>
</Tooltip>
);
})}
</div>
)}
</div>
</div>
);
@@ -2,6 +2,7 @@ import React from 'react';
import { RiDonutChartLine } from '@remixicon/react';
import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
interface ContextUsageDisplayProps {
totalTokens: number;
@@ -9,6 +10,7 @@ interface ContextUsageDisplayProps {
contextLimit: number;
outputLimit?: number;
size?: 'default' | 'compact';
isMobile?: boolean;
}
export const ContextUsageDisplay: React.FC<ContextUsageDisplayProps> = ({
@@ -17,7 +19,11 @@ export const ContextUsageDisplay: React.FC<ContextUsageDisplayProps> = ({
contextLimit,
outputLimit,
size = 'default',
isMobile = false,
}) => {
const [mobileTooltipOpen, setMobileTooltipOpen] = React.useState(false);
const longPressTimerRef = React.useRef<NodeJS.Timeout | undefined>(undefined);
const formatTokens = (tokens: number) => {
if (tokens >= 1_000_000) {
return `${(tokens / 1_000_000).toFixed(1)}M`;
@@ -28,9 +34,9 @@ export const ContextUsageDisplay: React.FC<ContextUsageDisplayProps> = ({
return tokens.toFixed(1).replace(/\.0$/, '');
};
const getPercentageColor = (percentage: number) => {
if (percentage >= 90) return 'text-status-error';
if (percentage >= 75) return 'text-status-warning';
const getPercentageColor = (pct: number) => {
if (pct >= 90) return 'text-status-error';
if (pct >= 75) return 'text-status-warning';
return 'text-status-success';
};
@@ -41,6 +47,29 @@ export const ContextUsageDisplay: React.FC<ContextUsageDisplayProps> = ({
`Output limit: ${formatTokens(safeOutputLimit)}`,
];
const handleLongPressStart = React.useCallback(() => {
if (longPressTimerRef.current) {
clearTimeout(longPressTimerRef.current);
}
longPressTimerRef.current = setTimeout(() => {
setMobileTooltipOpen(true);
}, 500);
}, []);
const handleLongPressEnd = React.useCallback(() => {
if (longPressTimerRef.current) {
clearTimeout(longPressTimerRef.current);
}
}, []);
React.useEffect(() => {
return () => {
if (longPressTimerRef.current) {
clearTimeout(longPressTimerRef.current);
}
};
}, []);
const contextElement = (
<div
className={cn(
@@ -48,6 +77,8 @@ export const ContextUsageDisplay: React.FC<ContextUsageDisplayProps> = ({
size === 'compact' ? 'typography-micro' : 'typography-meta',
)}
aria-label="Context usage"
onTouchStart={isMobile ? handleLongPressStart : undefined}
onTouchEnd={isMobile ? handleLongPressEnd : undefined}
>
<RiDonutChartLine className="h-4 w-4 flex-shrink-0" />
<span className={cn(getPercentageColor(percentage), 'font-medium')}>
@@ -56,6 +87,42 @@ export const ContextUsageDisplay: React.FC<ContextUsageDisplayProps> = ({
</div>
);
if (isMobile) {
return (
<>
{contextElement}
<MobileOverlayPanel
open={mobileTooltipOpen}
onClose={() => setMobileTooltipOpen(false)}
title="Context Usage"
>
<div className="flex flex-col gap-1.5">
<div className="rounded-xl border border-border/40 bg-sidebar/30 px-3 py-2 space-y-1">
<div className="flex justify-between items-center">
<span className="typography-meta text-muted-foreground">Used tokens</span>
<span className="typography-meta text-foreground font-medium">{formatTokens(totalTokens)}</span>
</div>
<div className="flex justify-between items-center">
<span className="typography-meta text-muted-foreground">Context limit</span>
<span className="typography-meta text-foreground font-medium">{formatTokens(contextLimit)}</span>
</div>
<div className="flex justify-between items-center">
<span className="typography-meta text-muted-foreground">Output limit</span>
<span className="typography-meta text-foreground font-medium">{formatTokens(safeOutputLimit)}</span>
</div>
<div className="flex justify-between items-center pt-1 border-t border-border/40">
<span className="typography-meta text-muted-foreground">Usage</span>
<span className={cn('typography-meta font-semibold', getPercentageColor(percentage))}>
{Math.min(percentage, 999).toFixed(1)}%
</span>
</div>
</div>
</div>
</MobileOverlayPanel>
</>
);
}
return (
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>{contextElement}</TooltipTrigger>
@@ -64,14 +64,14 @@ const FileSelector = React.memo<FileSelectorProps>(({
onSelectFile,
isMobile,
}) => {
if (changedFiles.length === 0) return null;
const getLabel = React.useCallback((path: string) => {
if (!isMobile) return path;
const lastSlash = path.lastIndexOf('/');
return lastSlash >= 0 ? path.slice(lastSlash + 1) : path;
}, [isMobile]);
if (changedFiles.length === 0) return null;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>