feat: session info popover on header click
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState, useRef, useEffect } from 'react';
|
||||||
import { Menu, Sparkles, LogOut, Volume2, VolumeOff, Cpu, Bot, Download, Minimize2 } from 'lucide-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 type { ConnectionStatus, Session, ChatMessage } from '../types';
|
||||||
import { useT } from '../hooks/useLocale';
|
import { useT } from '../hooks/useLocale';
|
||||||
import { LanguageSelector } from './LanguageSelector';
|
import { LanguageSelector } from './LanguageSelector';
|
||||||
@@ -24,6 +24,20 @@ interface Props {
|
|||||||
export function Header({ status, sessionKey, onToggleSidebar, activeSessionData, onLogout, soundEnabled, onToggleSound, messages, agentAvatarUrl, agentName, onCompact }: Props) {
|
export function Header({ status, sessionKey, onToggleSidebar, activeSessionData, onLogout, soundEnabled, onToggleSound, messages, agentAvatarUrl, agentName, onCompact }: Props) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const sessionLabel = activeSessionData ? sessionDisplayName(activeSessionData) : (sessionKey.split(':').pop() || sessionKey);
|
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(() => {
|
const handleExport = useCallback(() => {
|
||||||
if (!messages || messages.length === 0) return;
|
if (!messages || messages.length === 0) return;
|
||||||
@@ -39,9 +53,9 @@ export function Header({ status, sessionKey, onToggleSidebar, activeSessionData,
|
|||||||
<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">
|
<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} />
|
<Menu size={20} />
|
||||||
</button>
|
</button>
|
||||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
<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'; } }} />
|
<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'; } }} />
|
||||||
<div className="min-w-0">
|
<button className="min-w-0 text-left group" onClick={() => setShowSessionInfo(v => !v)} title={t('header.sessionInfo')}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-semibold text-pc-text text-sm tracking-wide">{agentName || t('header.title')}</span>
|
<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" />
|
<Sparkles className="h-3.5 w-3.5 text-pc-accent-light/60" />
|
||||||
@@ -55,8 +69,12 @@ export function Header({ status, sessionKey, onToggleSidebar, activeSessionData,
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{sessionLabel}
|
{sessionLabel}
|
||||||
|
<Info className="h-3 w-3 text-pc-text-faint opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</button>
|
||||||
|
{showSessionInfo && activeSessionData && (
|
||||||
|
<SessionInfoPopover session={activeSessionData} sessionKey={sessionKey} messageCount={messages?.length ?? 0} onClose={() => setShowSessionInfo(false)} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
{onToggleSound && (
|
{onToggleSound && (
|
||||||
@@ -140,6 +158,62 @@ export function Header({ status, sessionKey, onToggleSidebar, activeSessionData,
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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> }) {
|
function CompactButton({ sessionKey, onCompact }: { sessionKey: string; onCompact: (key: string) => Promise<boolean> }) {
|
||||||
const [compacting, setCompacting] = useState(false);
|
const [compacting, setCompacting] = useState(false);
|
||||||
const t = useT();
|
const t = useT();
|
||||||
|
|||||||
@@ -123,6 +123,18 @@ const en = {
|
|||||||
'header.export': 'Export conversation as Markdown',
|
'header.export': 'Export conversation as Markdown',
|
||||||
'header.compact': 'Compact',
|
'header.compact': 'Compact',
|
||||||
'header.compacting': 'Compacting…',
|
'header.compacting': 'Compacting…',
|
||||||
|
'header.sessionInfo': 'Session Info',
|
||||||
|
'sessionInfo.sessionKey': 'Session Key',
|
||||||
|
'sessionInfo.channel': 'Channel',
|
||||||
|
'sessionInfo.kind': 'Kind',
|
||||||
|
'sessionInfo.model': 'Model',
|
||||||
|
'sessionInfo.agent': 'Agent',
|
||||||
|
'sessionInfo.messages': 'Messages',
|
||||||
|
'sessionInfo.totalTokens': 'Total Tokens',
|
||||||
|
'sessionInfo.inputTokens': 'Input',
|
||||||
|
'sessionInfo.outputTokens': 'Output',
|
||||||
|
'sessionInfo.contextWindow': 'Context',
|
||||||
|
'sessionInfo.lastActive': 'Last Active',
|
||||||
|
|
||||||
// Theme
|
// Theme
|
||||||
'theme.title': 'Theme',
|
'theme.title': 'Theme',
|
||||||
@@ -265,6 +277,18 @@ const fr: Record<keyof typeof en, string> = {
|
|||||||
'header.export': 'Exporter la conversation en Markdown',
|
'header.export': 'Exporter la conversation en Markdown',
|
||||||
'header.compact': 'Compacter',
|
'header.compact': 'Compacter',
|
||||||
'header.compacting': 'Compaction…',
|
'header.compacting': 'Compaction…',
|
||||||
|
'header.sessionInfo': 'Infos session',
|
||||||
|
'sessionInfo.sessionKey': 'Clé session',
|
||||||
|
'sessionInfo.channel': 'Canal',
|
||||||
|
'sessionInfo.kind': 'Type',
|
||||||
|
'sessionInfo.model': 'Modèle',
|
||||||
|
'sessionInfo.agent': 'Agent',
|
||||||
|
'sessionInfo.messages': 'Messages',
|
||||||
|
'sessionInfo.totalTokens': 'Tokens total',
|
||||||
|
'sessionInfo.inputTokens': 'Entrée',
|
||||||
|
'sessionInfo.outputTokens': 'Sortie',
|
||||||
|
'sessionInfo.contextWindow': 'Contexte',
|
||||||
|
'sessionInfo.lastActive': 'Dernière activité',
|
||||||
|
|
||||||
'theme.title': 'Thème',
|
'theme.title': 'Thème',
|
||||||
'theme.mode': 'Mode',
|
'theme.mode': 'Mode',
|
||||||
|
|||||||
Reference in New Issue
Block a user