From fbb63b920cf57c4cfe55430f1103be4c34d2ee16 Mon Sep 17 00:00:00 2001 From: Nicolas Varrot Date: Sat, 14 Feb 2026 16:25:08 +0000 Subject: [PATCH] feat: session info popover on header click --- src/components/Header.tsx | 84 ++++++++++++++++++++++++++++++++++++--- src/lib/i18n.ts | 24 +++++++++++ 2 files changed, 103 insertions(+), 5 deletions(-) diff --git a/src/components/Header.tsx b/src/components/Header.tsx index eed2430..559e8e3 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,5 +1,5 @@ -import { useCallback, useState } from 'react'; -import { Menu, Sparkles, LogOut, Volume2, VolumeOff, Cpu, Bot, Download, Minimize2 } from 'lucide-react'; +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'; @@ -24,6 +24,20 @@ interface Props { 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(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; @@ -39,9 +53,9 @@ export function Header({ status, sessionKey, onToggleSidebar, activeSessionData, -
+
PinchChat { const img = e.target as HTMLImageElement; if (img.src !== window.location.origin + '/logo.png') { img.src = '/logo.png'; } else { img.style.display = 'none'; } }} /> -
+ + {showSessionInfo && activeSessionData && ( + setShowSessionInfo(false)} /> + )}
{onToggleSound && ( @@ -140,6 +158,62 @@ export function Header({ status, sessionKey, onToggleSidebar, activeSessionData, ); } +function CopyField({ value }: { value: string }) { + const [copied, setCopied] = useState(false); + return ( + + ); +} + +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 ( +
+
+ {t('header.sessionInfo')} + +
+
+ {rows.map(({ label, value, copyable }) => ( +
+ {label} + {value} + {copyable && } +
+ ))} +
+
+ ); +} + function CompactButton({ sessionKey, onCompact }: { sessionKey: string; onCompact: (key: string) => Promise }) { const [compacting, setCompacting] = useState(false); const t = useT(); diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index 360c8be..ed08fa1 100644 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -123,6 +123,18 @@ const en = { 'header.export': 'Export conversation as Markdown', 'header.compact': 'Compact', '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.title': 'Theme', @@ -265,6 +277,18 @@ const fr: Record = { 'header.export': 'Exporter la conversation en Markdown', 'header.compact': 'Compacter', '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.mode': 'Mode',