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, 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,101 +357,62 @@ 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} 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"
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"
aria-label="Open sessions" >
> <RiPlayListAddLine className="h-5 w-5" />
<RiPlayListAddLine className="h-5 w-5" /> </button>
</button> {contextUsage && contextUsage.totalTokens > 0 && activeMainTab === 'chat' && (
{contextUsage && contextUsage.totalTokens > 0 && activeMainTab === 'chat' && ( <ContextUsageDisplay
<ContextUsageDisplay totalTokens={contextUsage.totalTokens}
totalTokens={contextUsage.totalTokens} percentage={contextUsage.percentage}
percentage={contextUsage.percentage} contextLimit={contextUsage.contextLimit}
contextLimit={contextUsage.contextLimit} outputLimit={contextUsage.outputLimit ?? 0}
outputLimit={contextUsage.outputLimit ?? 0} size="compact"
size="compact" isMobile={true}
/> />
)} )}
</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> </div>
{isMobileDetailsOpen && ( <div className="app-region-no-drag flex items-center gap-1.5">
<div <div className="flex items-center" role="tablist" aria-label="Main navigation">
id="mobile-header-details" {tabs.map((tab) => {
className="app-region-no-drag absolute left-0 right-0 top-full z-40 translate-y-2 px-3" const isActive = activeMainTab === tab.id;
> const Icon = tab.icon;
<div className="flex flex-col gap-4 rounded-xl border border-border/40 bg-background/95 px-3 py-3 shadow-none"> return (
{contextUsage && contextUsage.totalTokens > 0 && ( <Tooltip key={tab.id} delayDuration={500}>
<div className="flex flex-col gap-1"> <TooltipTrigger asChild>
<span className="typography-micro text-muted-foreground">Context usage</span> <button
<div className="rounded-lg border border-border/40 bg-muted/10 px-3 py-2 space-y-0.5"> type="button"
<p className="typography-meta"> onClick={() => setActiveMainTab(tab.id)}
Used tokens: <span className="font-semibold text-foreground">{formatTokenValue(contextUsage.totalTokens)}</span> aria-label={tab.label}
</p> aria-selected={isActive}
<p className="typography-meta"> role="tab"
Context limit: <span className="font-semibold text-foreground">{formatTokenValue(contextUsage.contextLimit)}</span> className={cn(
</p> headerIconButtonClass,
<p className="typography-meta"> isActive && 'text-foreground'
Output limit: <span className="font-semibold text-foreground">{formatTokenValue(contextUsage.outputLimit ?? 0)}</span> )}
</p> >
</div> <Icon className="h-5 w-5" />
</div> {tab.badge !== undefined && tab.badge > 0 && (
)} <span className="absolute -top-1 -right-1 text-[10px] font-semibold text-primary">
</div> {tab.badge}
</span>
)}
</button>
</TooltipTrigger>
<TooltipContent>
<p>{tab.label}</p>
</TooltipContent>
</Tooltip>
);
})}
</div> </div>
)} </div>
</div> </div>
); );
@@ -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>