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
+5 -55
View File
@@ -4,8 +4,8 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip'; } 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 { useUIStore, type MainTab } from '@/stores/useUIStore';
import { useConfigStore } from '@/stores/useConfigStore'; import { useConfigStore } from '@/stores/useConfigStore';
import { useSessionStore } from '@/stores/useSessionStore'; 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 contextLimit = (limit && typeof limit.context === 'number' ? limit.context : 0);
const outputLimit = (limit && typeof limit.output === 'number' ? limit.output : 0); const outputLimit = (limit && typeof limit.output === 'number' ? limit.output : 0);
const contextUsage = getContextUsage(contextLimit, outputLimit); const contextUsage = getContextUsage(contextLimit, outputLimit);
const [isMobileDetailsOpen, setIsMobileDetailsOpen] = React.useState(false);
const isSessionSwitcherOpen = useUIStore((state) => state.isSessionSwitcherOpen); const isSessionSwitcherOpen = useUIStore((state) => state.isSessionSwitcherOpen);
const handleOpenSessionSwitcher = React.useCallback(() => { const handleOpenSessionSwitcher = React.useCallback(() => {
@@ -182,17 +181,7 @@ export const Header: React.FC = () => {
useEffect(() => { useEffect(() => {
updateHeaderHeight(); updateHeaderHeight();
}, [updateHeaderHeight, isMobile, isMobileDetailsOpen]); }, [updateHeaderHeight, isMobile]);
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$/, '');
}, []);
const handleDragStart = React.useCallback(async (e: React.MouseEvent) => { const handleDragStart = React.useCallback(async (e: React.MouseEvent) => {
@@ -368,8 +357,7 @@ export const Header: React.FC = () => {
); );
const renderMobile = () => ( const renderMobile = () => (
<div className="app-region-drag relative flex flex-col gap-1 px-3 py-2 select-none"> <div className="app-region-drag relative flex items-center justify-between gap-2 px-3 py-2 select-none">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={handleOpenSessionSwitcher} onClick={handleOpenSessionSwitcher}
@@ -385,12 +373,12 @@ export const Header: React.FC = () => {
contextLimit={contextUsage.contextLimit} contextLimit={contextUsage.contextLimit}
outputLimit={contextUsage.outputLimit ?? 0} outputLimit={contextUsage.outputLimit ?? 0}
size="compact" size="compact"
isMobile={true}
/> />
)} )}
</div> </div>
<div className="app-region-no-drag flex items-center gap-1.5"> <div className="app-region-no-drag flex items-center gap-1.5">
{}
<div className="flex items-center" role="tablist" aria-label="Main navigation"> <div className="flex items-center" role="tablist" aria-label="Main navigation">
{tabs.map((tab) => { {tabs.map((tab) => {
const isActive = activeMainTab === tab.id; const isActive = activeMainTab === tab.id;
@@ -424,46 +412,8 @@ export const Header: React.FC = () => {
); );
})} })}
</div> </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>
</div> </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>
)}
</div>
); );
const headerClassName = cn( const headerClassName = cn(
@@ -2,6 +2,7 @@ import React from 'react';
import { RiDonutChartLine } from '@remixicon/react'; import { RiDonutChartLine } from '@remixicon/react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
interface ContextUsageDisplayProps { interface ContextUsageDisplayProps {
totalTokens: number; totalTokens: number;
@@ -9,6 +10,7 @@ interface ContextUsageDisplayProps {
contextLimit: number; contextLimit: number;
outputLimit?: number; outputLimit?: number;
size?: 'default' | 'compact'; size?: 'default' | 'compact';
isMobile?: boolean;
} }
export const ContextUsageDisplay: React.FC<ContextUsageDisplayProps> = ({ export const ContextUsageDisplay: React.FC<ContextUsageDisplayProps> = ({
@@ -17,7 +19,11 @@ export const ContextUsageDisplay: React.FC<ContextUsageDisplayProps> = ({
contextLimit, contextLimit,
outputLimit, outputLimit,
size = 'default', size = 'default',
isMobile = false,
}) => { }) => {
const [mobileTooltipOpen, setMobileTooltipOpen] = React.useState(false);
const longPressTimerRef = React.useRef<NodeJS.Timeout | undefined>(undefined);
const formatTokens = (tokens: number) => { const formatTokens = (tokens: number) => {
if (tokens >= 1_000_000) { if (tokens >= 1_000_000) {
return `${(tokens / 1_000_000).toFixed(1)}M`; return `${(tokens / 1_000_000).toFixed(1)}M`;
@@ -28,9 +34,9 @@ export const ContextUsageDisplay: React.FC<ContextUsageDisplayProps> = ({
return tokens.toFixed(1).replace(/\.0$/, ''); return tokens.toFixed(1).replace(/\.0$/, '');
}; };
const getPercentageColor = (percentage: number) => { const getPercentageColor = (pct: number) => {
if (percentage >= 90) return 'text-status-error'; if (pct >= 90) return 'text-status-error';
if (percentage >= 75) return 'text-status-warning'; if (pct >= 75) return 'text-status-warning';
return 'text-status-success'; return 'text-status-success';
}; };
@@ -41,6 +47,29 @@ export const ContextUsageDisplay: React.FC<ContextUsageDisplayProps> = ({
`Output limit: ${formatTokens(safeOutputLimit)}`, `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 = ( const contextElement = (
<div <div
className={cn( className={cn(
@@ -48,6 +77,8 @@ export const ContextUsageDisplay: React.FC<ContextUsageDisplayProps> = ({
size === 'compact' ? 'typography-micro' : 'typography-meta', size === 'compact' ? 'typography-micro' : 'typography-meta',
)} )}
aria-label="Context usage" aria-label="Context usage"
onTouchStart={isMobile ? handleLongPressStart : undefined}
onTouchEnd={isMobile ? handleLongPressEnd : undefined}
> >
<RiDonutChartLine className="h-4 w-4 flex-shrink-0" /> <RiDonutChartLine className="h-4 w-4 flex-shrink-0" />
<span className={cn(getPercentageColor(percentage), 'font-medium')}> <span className={cn(getPercentageColor(percentage), 'font-medium')}>
@@ -56,6 +87,42 @@ export const ContextUsageDisplay: React.FC<ContextUsageDisplayProps> = ({
</div> </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 ( return (
<Tooltip delayDuration={1000}> <Tooltip delayDuration={1000}>
<TooltipTrigger asChild>{contextElement}</TooltipTrigger> <TooltipTrigger asChild>{contextElement}</TooltipTrigger>
@@ -64,14 +64,14 @@ const FileSelector = React.memo<FileSelectorProps>(({
onSelectFile, onSelectFile,
isMobile, isMobile,
}) => { }) => {
if (changedFiles.length === 0) return null;
const getLabel = React.useCallback((path: string) => { const getLabel = React.useCallback((path: string) => {
if (!isMobile) return path; if (!isMobile) return path;
const lastSlash = path.lastIndexOf('/'); const lastSlash = path.lastIndexOf('/');
return lastSlash >= 0 ? path.slice(lastSlash + 1) : path; return lastSlash >= 0 ? path.slice(lastSlash + 1) : path;
}, [isMobile]); }, [isMobile]);
if (changedFiles.length === 0) return null;
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>