Files
PinchChat/src/components/Header.tsx
2026-02-14 16:25:08 +00:00

243 lines
12 KiB
TypeScript

import { useCallback, useState, useRef, useEffect } from 'react';
import { Menu, Sparkles, LogOut, Volume2, VolumeOff, Cpu, Bot, Download, Minimize2, Info, Copy, Check } from 'lucide-react';
import type { ConnectionStatus, Session, ChatMessage } from '../types';
import { useT } from '../hooks/useLocale';
import { LanguageSelector } from './LanguageSelector';
import { ThemeSwitcher } from './ThemeSwitcher';
import { sessionDisplayName } from '../lib/sessionName';
import { messagesToMarkdown, downloadFile } from '../lib/exportChat';
interface Props {
status: ConnectionStatus;
sessionKey: string;
onToggleSidebar: () => void;
activeSessionData?: Session;
onLogout?: () => void;
soundEnabled?: boolean;
onToggleSound?: () => void;
messages?: ChatMessage[];
agentAvatarUrl?: string;
agentName?: string;
onCompact?: (sessionKey: string) => Promise<boolean>;
}
export function Header({ status, sessionKey, onToggleSidebar, activeSessionData, onLogout, soundEnabled, onToggleSound, messages, agentAvatarUrl, agentName, onCompact }: Props) {
const t = useT();
const sessionLabel = activeSessionData ? sessionDisplayName(activeSessionData) : (sessionKey.split(':').pop() || sessionKey);
const [showSessionInfo, setShowSessionInfo] = useState(false);
const sessionInfoRef = useRef<HTMLDivElement>(null);
// Close popover on outside click
useEffect(() => {
if (!showSessionInfo) return;
const handler = (e: MouseEvent) => {
if (sessionInfoRef.current && !sessionInfoRef.current.contains(e.target as Node)) {
setShowSessionInfo(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [showSessionInfo]);
const handleExport = useCallback(() => {
if (!messages || messages.length === 0) return;
const md = messagesToMarkdown(messages, sessionLabel);
const safeLabel = sessionLabel.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 50);
const date = new Date().toISOString().slice(0, 10);
downloadFile(md, `${safeLabel}_${date}.md`);
}, [messages, sessionLabel]);
return (
<>
<header className="h-14 border-b border-pc-border bg-[var(--pc-bg-surface)]/90 backdrop-blur-xl flex items-center px-4 gap-3 shrink-0" role="banner">
<button onClick={onToggleSidebar} aria-label={t('header.toggleSidebar')} className="lg:hidden p-2 rounded-2xl hover:bg-[var(--pc-hover)] text-pc-text-secondary transition-colors">
<Menu size={20} />
</button>
<div className="flex items-center gap-3 flex-1 min-w-0 relative" ref={sessionInfoRef}>
<img src={agentAvatarUrl || '/logo.png'} alt="PinchChat" className="h-9 w-9 rounded-2xl object-cover" onError={(e) => { const img = e.target as HTMLImageElement; if (img.src !== window.location.origin + '/logo.png') { img.src = '/logo.png'; } else { img.style.display = 'none'; } }} />
<button className="min-w-0 text-left group" onClick={() => setShowSessionInfo(v => !v)} title={t('header.sessionInfo')}>
<div className="flex items-center gap-2">
<span className="font-semibold text-pc-text text-sm tracking-wide">{agentName || t('header.title')}</span>
<Sparkles className="h-3.5 w-3.5 text-pc-accent-light/60" />
</div>
<span className="text-xs text-pc-text-muted truncate flex items-center gap-1.5">
{activeSessionData?.agentId && (
<span className="inline-flex items-center gap-0.5 text-pc-accent/70 font-medium">
<Bot className="h-3 w-3" />
{activeSessionData.agentId}
<span className="text-pc-text-faint mx-0.5">·</span>
</span>
)}
{sessionLabel}
<Info className="h-3 w-3 text-pc-text-faint opacity-0 group-hover:opacity-100 transition-opacity" />
</span>
</button>
{showSessionInfo && activeSessionData && (
<SessionInfoPopover session={activeSessionData} sessionKey={sessionKey} messageCount={messages?.length ?? 0} onClose={() => setShowSessionInfo(false)} />
)}
</div>
<div className="flex items-center gap-2 text-sm">
{onToggleSound && (
<button
onClick={onToggleSound}
aria-label={soundEnabled ? t('header.soundOff') : t('header.soundOn')}
className="hidden sm:block p-2 rounded-2xl hover:bg-[var(--pc-hover)] text-pc-text-muted hover:text-pc-text transition-colors"
title={soundEnabled ? t('header.soundOff') : t('header.soundOn')}
>
{soundEnabled ? <Volume2 size={16} /> : <VolumeOff size={16} />}
</button>
)}
{messages && messages.length > 0 && (
<button
onClick={handleExport}
aria-label={t('header.export')}
className="hidden sm:block p-2 rounded-2xl hover:bg-[var(--pc-hover)] text-pc-text-muted hover:text-pc-text transition-colors"
title={t('header.export')}
>
<Download size={16} />
</button>
)}
<span className="hidden sm:contents"><ThemeSwitcher /></span>
<span className="hidden sm:contents"><LanguageSelector /></span>
{status === 'connected' ? (
<div className="flex items-center gap-2 rounded-2xl border border-pc-border bg-pc-elevated/30 px-3 py-1.5">
<span className="w-2 h-2 rounded-full bg-emerald-400 shadow-[0_0_12px_rgba(52,211,153,0.4)]" />
<span className="text-xs text-pc-text hidden sm:inline">{t('header.connected')}</span>
</div>
) : status === 'connecting' ? (
<div className="flex items-center gap-2 rounded-2xl border border-pc-border bg-pc-elevated/30 px-3 py-1.5">
<span className="w-2 h-2 rounded-full bg-yellow-400/80 pulse-dot" />
<span className="text-xs text-pc-text hidden sm:inline">{t('login.connecting')}</span>
</div>
) : (
<div className="flex items-center gap-2 rounded-2xl border border-pc-border bg-pc-elevated/30 px-3 py-1.5">
<span className="w-2 h-2 rounded-full bg-red-400/80" />
<span className="text-xs text-pc-text hidden sm:inline">{t('header.disconnected')}</span>
</div>
)}
{onLogout && (
<button
onClick={onLogout}
aria-label={t('header.logout')}
className="p-2 rounded-2xl hover:bg-[var(--pc-hover)] text-pc-text-muted hover:text-pc-text transition-colors"
title={t('header.logout')}
>
<LogOut size={16} />
</button>
)}
</div>
</header>
{(() => {
const ctx = activeSessionData?.contextTokens;
const total = activeSessionData?.totalTokens || 0;
if (!ctx) return null;
const pct = Math.min(100, (total / ctx) * 100);
const opacity = Math.max(0.35, Math.min(1, pct / 100));
const barStyle = { width: `${pct}%`, backgroundColor: `rgba(var(--pc-accent-rgb), ${opacity})` };
return (
<div className="px-4 py-1.5 bg-[var(--pc-bg-surface)]/60 border-b border-pc-border flex items-center gap-3">
{activeSessionData?.model && (
<span className="inline-flex items-center gap-1 text-[10px] text-pc-text-muted shrink-0" title={`Model: ${activeSessionData.model}${activeSessionData.agentId ? ` · Agent: ${activeSessionData.agentId}` : ''}`}>
<Cpu className="h-2.5 w-2.5" />
<span className="truncate max-w-[120px]">{activeSessionData.model.replace(/^.*\//, '')}</span>
</span>
)}
<div className="flex-1 h-[5px] rounded-full bg-[var(--pc-hover)] overflow-hidden">
<div className="h-full rounded-full transition-all duration-500" style={barStyle} />
</div>
<span className="text-[11px] text-pc-text-secondary tabular-nums shrink-0 whitespace-nowrap">
{(total / 1000).toFixed(1)}k / {(ctx / 1000).toFixed(0)}k tokens
</span>
{onCompact && pct >= 50 && (
<CompactButton sessionKey={sessionKey} onCompact={onCompact} />
)}
</div>
);
})()}
</>
);
}
function CopyField({ value }: { value: string }) {
const [copied, setCopied] = useState(false);
return (
<button
className="ml-auto p-0.5 rounded hover:bg-[var(--pc-hover)] text-pc-text-faint hover:text-pc-text-secondary transition-colors"
onClick={() => { navigator.clipboard.writeText(value).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1500); }); }}
title="Copy"
>
{copied ? <Check size={11} className="text-emerald-400" /> : <Copy size={11} />}
</button>
);
}
function SessionInfoPopover({ session, sessionKey, messageCount, onClose }: { session: Session; sessionKey: string; messageCount: number; onClose: () => void }) {
const t = useT();
const rows: Array<{ label: string; value: string; copyable?: boolean }> = [
{ label: t('sessionInfo.sessionKey'), value: sessionKey, copyable: true },
];
if (session.channel) rows.push({ label: t('sessionInfo.channel'), value: session.channel });
if (session.kind) rows.push({ label: t('sessionInfo.kind'), value: session.kind });
if (session.model) rows.push({ label: t('sessionInfo.model'), value: session.model.replace(/^.*\//, '') });
if (session.agentId) rows.push({ label: t('sessionInfo.agent'), value: session.agentId });
rows.push({ label: t('sessionInfo.messages'), value: String(messageCount) });
if (session.totalTokens) {
rows.push({ label: t('sessionInfo.totalTokens'), value: `${(session.totalTokens / 1000).toFixed(1)}k` });
if (session.inputTokens) rows.push({ label: t('sessionInfo.inputTokens'), value: `${(session.inputTokens / 1000).toFixed(1)}k` });
if (session.outputTokens) rows.push({ label: t('sessionInfo.outputTokens'), value: `${(session.outputTokens / 1000).toFixed(1)}k` });
if (session.contextTokens) rows.push({ label: t('sessionInfo.contextWindow'), value: `${(session.contextTokens / 1000).toFixed(0)}k` });
}
if (session.updatedAt) {
rows.push({ label: t('sessionInfo.lastActive'), value: new Date(session.updatedAt).toLocaleString() });
}
return (
<div
className="absolute top-full left-0 mt-2 z-50 w-72 rounded-xl border border-pc-border bg-[var(--pc-bg-surface)] shadow-xl backdrop-blur-xl animate-in fade-in slide-in-from-top-2 duration-200"
role="dialog"
aria-label={t('header.sessionInfo')}
>
<div className="p-3 border-b border-pc-border flex items-center justify-between">
<span className="text-xs font-semibold text-pc-text">{t('header.sessionInfo')}</span>
<button onClick={onClose} className="text-pc-text-faint hover:text-pc-text text-xs"></button>
</div>
<div className="p-3 space-y-2">
{rows.map(({ label, value, copyable }) => (
<div key={label} className="flex items-start gap-2 text-[11px]">
<span className="text-pc-text-muted shrink-0 w-20">{label}</span>
<span className="text-pc-text-secondary break-all flex-1 font-mono">{value}</span>
{copyable && <CopyField value={value} />}
</div>
))}
</div>
</div>
);
}
function CompactButton({ sessionKey, onCompact }: { sessionKey: string; onCompact: (key: string) => Promise<boolean> }) {
const [compacting, setCompacting] = useState(false);
const t = useT();
const handleCompact = useCallback(async () => {
if (compacting) return;
setCompacting(true);
try {
await onCompact(sessionKey);
} finally {
setCompacting(false);
}
}, [compacting, sessionKey, onCompact]);
return (
<button
onClick={handleCompact}
disabled={compacting}
className="inline-flex items-center gap-1 text-[10px] text-pc-text-muted hover:text-pc-text shrink-0 px-1.5 py-0.5 rounded hover:bg-[var(--pc-hover)] transition-colors disabled:opacity-50"
title={t('header.compact')}
>
<Minimize2 className={`h-3 w-3 ${compacting ? 'animate-pulse' : ''}`} />
<span className="hidden sm:inline">{compacting ? t('header.compacting') : t('header.compact')}</span>
</button>
);
}