Files
heicode-win/cc-haha/desktop/src/components/layout/Sidebar.tsx
T
chenchenandClaude Opus 4.7 ac1aa87312 release: 0.1.10 — balance widget actually shows + avatar ring
Balance pill was rendering nothing for admin/root accounts because
mcp-server's §4 returns HEICODE_USER_NOT_FOUND for users that never
came through from-agnet onboarding. Fall back to Heicode NewAPI's
own /api/user/self when that happens; reshape into the same envelope
so the UI is path-agnostic.

New balanceStore (zustand) — single polling loop, BalanceBar +
avatar ring share it. AppShell starts it once auth bootstraps.
BalanceBar now shows a "loading…" placeholder on first fetch so
the widget is visible from frame one.

SidebarUserCard avatar wears an SVG ring whose arc length tracks
remaining/(remaining+used) and color hits the same green→amber→red
thresholds as the bar.

Manager: /desktop-client drops the manifest-notes wall of text, the
old HEICODE_DESKTOP_FILE_* subtitle goes away, and a Mac fallback
entry is always spliced in when the live manifest is Win-only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 16:44:13 +08:00

806 lines
33 KiB
TypeScript

import { useEffect, useState, useCallback, useMemo, useRef } from 'react'
import { useSessionStore } from '../../stores/sessionStore'
import { useUIStore } from '../../stores/uiStore'
import { useTranslation } from '../../i18n'
import { ProjectFilter } from './ProjectFilter'
import { ConfirmDialog } from '../shared/ConfirmDialog'
import type { SessionListItem } from '../../types/session'
import {
useTabStore,
SETTINGS_TAB_ID,
SCHEDULED_TAB_ID,
HEICODE_TASKS_TAB_ID,
} from '../../stores/tabStore'
import { useChatStore } from '../../stores/chatStore'
import { useHeicodeAuthStore } from '../../stores/heicodeAuthStore'
import { useBalanceRemainingFraction } from '../../stores/balanceStore'
const isTauri = typeof window !== 'undefined' && ('__TAURI_INTERNALS__' in window || '__TAURI__' in window)
const isWindows = typeof navigator !== 'undefined' && /Win/.test(navigator.platform)
type TimeGroup = 'within1day' | 'within3days' | 'within7days' | 'older'
const TIME_GROUP_ORDER: TimeGroup[] = ['within1day', 'within3days', 'within7days', 'older']
// User-selectable time filter for the sidebar history list. 'all' means
// no filtering — all groups visible. Persisted in localStorage so a chosen
// window survives reloads.
type TimeFilter = 'all' | '1d' | '3d' | '7d'
const TIME_FILTER_OPTIONS: TimeFilter[] = ['all', '1d', '3d', '7d']
const TIME_FILTER_KEY = 'cc-haha.sidebar.timeFilter'
function loadTimeFilter(): TimeFilter {
if (typeof window === 'undefined') return 'all'
const v = window.localStorage.getItem(TIME_FILTER_KEY)
return TIME_FILTER_OPTIONS.includes(v as TimeFilter) ? (v as TimeFilter) : 'all'
}
function saveTimeFilter(v: TimeFilter) {
if (typeof window === 'undefined') return
try { window.localStorage.setItem(TIME_FILTER_KEY, v) } catch { /* quota */ }
}
function timeFilterCutoffMs(filter: TimeFilter): number | null {
if (filter === 'all') return null
const days = filter === '1d' ? 1 : filter === '3d' ? 3 : 7
return Date.now() - days * 86400000
}
export function Sidebar() {
const sessions = useSessionStore((s) => s.sessions)
const selectedProjects = useSessionStore((s) => s.selectedProjects)
const error = useSessionStore((s) => s.error)
const fetchSessions = useSessionStore((s) => s.fetchSessions)
const deleteSession = useSessionStore((s) => s.deleteSession)
const renameSession = useSessionStore((s) => s.renameSession)
const addToast = useUIStore((s) => s.addToast)
const sidebarOpen = useUIStore((s) => s.sidebarOpen)
const toggleSidebar = useUIStore((s) => s.toggleSidebar)
const activeTabId = useTabStore((s) => s.activeTabId)
const activeTabType = useTabStore((s) => s.tabs.find((tab) => tab.sessionId === s.activeTabId)?.type)
const closeTab = useTabStore((s) => s.closeTab)
const disconnectSession = useChatStore((s) => s.disconnectSession)
const logout = useHeicodeAuthStore((s) => s.logout)
// Logged-in user identity, surfaced as a card right above logout/settings so
// the user can always SEE who they're signed in as without clicking anywhere.
// Populated by /api/heicode-auth/status from the active provider's mcpAuth
// record (credentials login → set in handleLoginWithCredentials; OAuth login
// → set from query params by handleOAuthCallback after backend a2deeb6).
const authUser = useHeicodeAuthStore((s) => s.status?.user ?? null)
const [searchQuery, setSearchQuery] = useState('')
const [contextMenu, setContextMenu] = useState<{ id: string; x: number; y: number } | null>(null)
const [pendingDeleteSessionId, setPendingDeleteSessionId] = useState<string | null>(null)
const [renamingId, setRenamingId] = useState<string | null>(null)
const [renameValue, setRenameValue] = useState('')
const [isLoggingOut, setIsLoggingOut] = useState(false)
const [timeFilter, setTimeFilter] = useState<TimeFilter>(() => loadTimeFilter())
const [timeFilterOpen, setTimeFilterOpen] = useState(false)
const timeFilterRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
fetchSessions()
}, [fetchSessions])
useEffect(() => {
if (!contextMenu || sidebarOpen) return
setContextMenu(null)
}, [contextMenu, sidebarOpen])
useEffect(() => {
if (!contextMenu) return
const close = () => setContextMenu(null)
document.addEventListener('click', close)
return () => document.removeEventListener('click', close)
}, [contextMenu])
const filteredSessions = useMemo(() => {
let result = sessions
if (selectedProjects.length > 0) {
result = result.filter((s) => selectedProjects.includes(s.projectPath))
}
if (searchQuery) {
const q = searchQuery.toLowerCase()
result = result.filter((s) => s.title.toLowerCase().includes(q))
}
const cutoff = timeFilterCutoffMs(timeFilter)
if (cutoff !== null) {
result = result.filter((s) => new Date(s.modifiedAt).getTime() >= cutoff)
}
return result
}, [sessions, selectedProjects, searchQuery, timeFilter])
// Click-outside to close the time-range popover.
useEffect(() => {
if (!timeFilterOpen) return
const onClick = (e: MouseEvent) => {
if (timeFilterRef.current && !timeFilterRef.current.contains(e.target as Node)) {
setTimeFilterOpen(false)
}
}
document.addEventListener('mousedown', onClick)
return () => document.removeEventListener('mousedown', onClick)
}, [timeFilterOpen])
const timeGroups = useMemo(() => groupByTime(filteredSessions), [filteredSessions])
const handleContextMenu = useCallback((e: React.MouseEvent, id: string) => {
e.preventDefault()
setContextMenu({ id, x: e.clientX, y: e.clientY })
}, [])
const handleDelete = useCallback((id: string) => {
setContextMenu(null)
setPendingDeleteSessionId(id)
}, [])
const confirmDelete = useCallback(async () => {
if (!pendingDeleteSessionId) return
await deleteSession(pendingDeleteSessionId)
disconnectSession(pendingDeleteSessionId)
closeTab(pendingDeleteSessionId)
setPendingDeleteSessionId(null)
}, [closeTab, deleteSession, disconnectSession, pendingDeleteSessionId])
const handleStartRename = useCallback((id: string, currentTitle: string) => {
setContextMenu(null)
setRenamingId(id)
setRenameValue(currentTitle)
}, [])
const handleFinishRename = useCallback(async () => {
if (renamingId && renameValue.trim()) {
await renameSession(renamingId, renameValue.trim())
}
setRenamingId(null)
setRenameValue('')
}, [renamingId, renameValue, renameSession])
const startDraggingRef = useRef<(() => Promise<void>) | null>(null)
useEffect(() => {
if (!isTauri) return
import(/* @vite-ignore */ '@tauri-apps/api/window')
.then(({ getCurrentWindow }) => {
const win = getCurrentWindow()
startDraggingRef.current = () => win.startDragging()
})
.catch(() => {})
}, [])
const handleSidebarDrag = useCallback((e: React.MouseEvent) => {
if ((e.target as HTMLElement).closest('button, input, textarea, select, a, [role="button"]')) return
startDraggingRef.current?.()
}, [])
const t = useTranslation()
const timeGroupLabels: Record<TimeGroup, string> = {
within1day: t('sidebar.timeGroup.within1day'),
within3days: t('sidebar.timeGroup.within3days'),
within7days: t('sidebar.timeGroup.within7days'),
older: t('sidebar.timeGroup.older'),
}
return (
<aside
onMouseDown={handleSidebarDrag}
className="sidebar-panel relative h-full flex flex-col bg-[var(--color-surface-sidebar)] border-r border-[var(--color-border)] select-none"
data-state={sidebarOpen ? 'open' : 'closed'}
aria-label="Sidebar"
>
<div className={`px-3 pb-2 ${isTauri && !isWindows ? 'pt-[44px]' : 'pt-3'}`}>
<div className={`flex ${sidebarOpen ? 'items-center justify-between gap-3' : 'flex-col items-center gap-2'}`}>
<div className={`flex min-w-0 items-center ${sidebarOpen ? 'gap-2.5' : 'justify-center'}`}>
<img src="/app-icon.png" alt="" className="h-8 w-8 flex-shrink-0" />
<span
className={`sidebar-copy ${sidebarOpen ? 'sidebar-copy--visible' : 'sidebar-copy--hidden'} text-[13px] font-semibold tracking-tight text-[var(--color-text-primary)]`}
style={{ fontFamily: 'var(--font-headline)' }}
>
HeiCode
</span>
</div>
<div className={`flex items-center ${sidebarOpen ? 'gap-1.5' : 'flex-col gap-2'}`}>
<button
type="button"
onClick={toggleSidebar}
data-testid={sidebarOpen ? 'sidebar-collapse-button' : 'sidebar-expand-button'}
className={`sidebar-toggle-button ${sidebarOpen ? 'sidebar-toggle-button--open h-8 w-8' : 'sidebar-toggle-button--collapsed h-8 w-8'} flex items-center justify-center rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-sidebar)]`}
aria-label={sidebarOpen ? t('sidebar.collapse') : t('sidebar.expand')}
title={sidebarOpen ? t('sidebar.collapse') : t('sidebar.expand')}
>
<SidebarToggleIcon collapsed={!sidebarOpen} />
</button>
</div>
</div>
</div>
<div className={`px-3 pb-3 flex flex-col ${sidebarOpen ? 'gap-0.5' : 'items-center gap-2'}`}>
<NavItem
active={false}
collapsed={!sidebarOpen}
label={t('sidebar.newSession')}
onClick={async () => {
try {
const currentTabId = useTabStore.getState().activeTabId
const currentSession = currentTabId
? useSessionStore.getState().sessions.find((s) => s.id === currentTabId)
: null
const workDir = currentSession?.workDir || undefined
const sessionId = await useSessionStore.getState().createSession(workDir)
useTabStore.getState().openTab(sessionId, t('sidebar.newSession'))
useChatStore.getState().connectToSession(sessionId)
} catch (error) {
addToast({
type: 'error',
message: error instanceof Error ? error.message : t('sidebar.sessionListFailed'),
})
}
}}
icon={<PlusIcon />}
>
{t('sidebar.newSession')}
</NavItem>
<NavItem
active={activeTabId === HEICODE_TASKS_TAB_ID}
collapsed={!sidebarOpen}
label={t('sidebar.heicodeTasks')}
onClick={() =>
useTabStore
.getState()
.openTab(HEICODE_TASKS_TAB_ID, t('sidebar.heicodeTasks'), 'heicode_tasks')
}
icon={<span className="material-symbols-outlined text-[18px]">target</span>}
>
{t('sidebar.heicodeTasks')}
</NavItem>
<NavItem
active={activeTabId === SCHEDULED_TAB_ID}
collapsed={!sidebarOpen}
label={t('sidebar.scheduled')}
onClick={() => useTabStore.getState().openTab(SCHEDULED_TAB_ID, t('sidebar.scheduled'), 'scheduled')}
icon={<ClockIcon />}
>
{t('sidebar.scheduled')}
</NavItem>
<NavItem
active={activeTabType === 'terminal'}
collapsed={!sidebarOpen}
label={t('sidebar.terminal')}
onClick={() => useTabStore.getState().openTerminalTab()}
icon={<span className="material-symbols-outlined text-[18px]">terminal</span>}
>
{t('sidebar.terminal')}
</NavItem>
</div>
{sidebarOpen ? (
<>
<div
data-testid="sidebar-project-filter-section"
className="sidebar-section sidebar-section--visible relative z-20 flex-none px-3 pb-2"
style={{ overflow: 'visible' }}
>
<div className="flex h-9 items-center rounded-[14px] border border-[var(--color-sidebar-search-border)] bg-[var(--color-sidebar-search-bg)] pl-1.5 pr-2 transition-colors focus-within:border-[var(--color-border-focus)]">
<ProjectFilter variant="embedded" />
<span className="mx-2 h-4 w-px bg-[var(--color-border)]/80" aria-hidden="true" />
<span className="pointer-events-none flex shrink-0 items-center text-[var(--color-text-tertiary)]">
<SearchIcon />
</span>
<input
id="sidebar-search"
type="text"
placeholder={t('sidebar.searchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="min-w-0 flex-1 bg-transparent pl-2 pr-0 text-[13px] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] outline-none"
/>
{/* Time-range filter — opens a popover with All / 1d / 3d / 7d.
The currently active option lights up brand color; a tiny
dot indicates non-default. */}
<div ref={timeFilterRef} className="relative ml-1 flex-shrink-0">
<button
type="button"
onClick={() => setTimeFilterOpen((v) => !v)}
title={t('sidebar.timeFilter.title')}
className={`flex h-7 items-center gap-1 rounded-full px-2 text-[11px] font-semibold transition-colors ${
timeFilter === 'all'
? 'text-[var(--color-text-tertiary)] hover:bg-[var(--color-surface-container)]'
: 'bg-[var(--color-brand)]/12 text-[var(--color-brand)]'
}`}
>
<span className="material-symbols-outlined text-[14px]">tune</span>
<span>{timeFilter === 'all' ? t('sidebar.timeFilter.all') : timeFilter}</span>
</button>
{timeFilterOpen && (
<div className="absolute right-0 top-9 z-50 w-32 rounded-[10px] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] py-1 shadow-[var(--shadow-dropdown)]">
{TIME_FILTER_OPTIONS.map((opt) => {
const label = opt === 'all' ? t('sidebar.timeFilter.all') : opt
const selected = timeFilter === opt
return (
<button
key={opt}
type="button"
onClick={() => {
setTimeFilter(opt)
saveTimeFilter(opt)
setTimeFilterOpen(false)
}}
className={`flex w-full items-center justify-between px-3 py-1.5 text-[12px] transition-colors ${
selected
? 'text-[var(--color-brand)]'
: 'text-[var(--color-text-primary)] hover:bg-[var(--color-surface-container)]'
}`}
>
<span>{label}</span>
{selected && (
<span className="material-symbols-outlined text-[14px]">check</span>
)}
</button>
)
})}
</div>
)}
</div>
</div>
</div>
<div
data-testid="sidebar-session-list-section"
className="sidebar-section sidebar-section--visible flex flex-1 min-h-0 flex-col"
>
<div className="sidebar-scroll-area min-h-0 flex-1 overflow-y-auto px-3">
{error && (
<div className="mx-1 mt-2 rounded-[var(--radius-md)] border border-[var(--color-error)]/20 bg-[var(--color-error)]/5 px-3 py-2">
<div className="text-xs font-medium text-[var(--color-error)]">{t('sidebar.sessionListFailed')}</div>
<div className="mt-1 text-[11px] text-[var(--color-text-secondary)] break-words">{error}</div>
<button
onClick={() => fetchSessions()}
className="mt-2 text-[11px] font-medium text-[var(--color-brand)] hover:underline"
>
{t('common.retry')}
</button>
</div>
)}
{filteredSessions.length === 0 && (
<div className="px-3 py-4 text-center text-xs text-[var(--color-text-tertiary)]">
{searchQuery ? t('sidebar.noMatching') : t('sidebar.noSessions')}
</div>
)}
{TIME_GROUP_ORDER.map((group) => {
const items = timeGroups.get(group)
if (!items || items.length === 0) return null
return (
<div key={group} className="mb-1">
<div className="px-2 pb-1 pt-4 text-[11px] font-semibold tracking-wide text-[var(--color-text-tertiary)]">
{timeGroupLabels[group]}
</div>
{items.map((session) => (
<div key={session.id} className="relative">
{renamingId === session.id ? (
<input
autoFocus
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={handleFinishRename}
onKeyDown={(e) => {
if (e.key === 'Enter') handleFinishRename()
if (e.key === 'Escape') {
setRenamingId(null)
setRenameValue('')
}
}}
className="ml-1 w-full rounded-[var(--radius-md)] border border-[var(--color-border-focus)] bg-[var(--color-surface)] px-3 py-2 text-sm text-[var(--color-text-primary)] outline-none"
/>
) : (
<button
onClick={() => {
useTabStore.getState().openTab(session.id, session.title)
useChatStore.getState().connectToSession(session.id)
}}
onContextMenu={(e) => handleContextMenu(e, session.id)}
className={`
group relative w-full rounded-[12px] px-3 py-2 text-left text-sm transition-all duration-200
${session.id === activeTabId
? 'bg-[var(--color-sidebar-item-active)] text-[var(--color-text-primary)] shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-sidebar-item-hover)] hover:text-[var(--color-text-primary)]'
}
`}
style={
session.id === activeTabId
? {
border:
'1px solid var(--color-sidebar-item-active-border)',
}
: { border: '1px solid transparent' }
}
>
{/* Bold violet rail on the left edge of the
active session — provides clear focus
anchor without crowding the text. */}
{session.id === activeTabId && (
<span
aria-hidden
className="pointer-events-none absolute left-0 top-1/2 h-5 w-0.5 -translate-y-1/2 rounded-r"
style={{
backgroundImage:
'var(--gradient-brand-wordmark)',
boxShadow:
'0 0 8px rgba(123,107,227,0.50)',
}}
/>
)}
<span className="flex items-center gap-2.5">
<span
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: session.id === activeTabId ? 'var(--color-brand)' : 'var(--color-text-tertiary)',
opacity: session.id === activeTabId ? 1 : 0.5,
boxShadow:
session.id === activeTabId
? '0 0 8px rgba(123,107,227,0.50)'
: 'none',
}}
/>
<span className="flex-1 truncate font-medium tracking-[-0.01em]">{session.title || 'Untitled'}</span>
{!session.workDirExists && (
<span
className="flex-shrink-0 text-[10px] text-[var(--color-warning)]"
title={session.workDir ?? ''}
>
{t('sidebar.missingDir')}
</span>
)}
<span className="flex-shrink-0 text-[10px] text-[var(--color-text-tertiary)] opacity-0 transition-opacity group-hover:opacity-100">
{formatRelativeTime(session.modifiedAt)}
</span>
</span>
</button>
)}
</div>
))}
</div>
)
})}
</div>
</div>
</>
) : (
<div className="flex-1" aria-hidden="true" />
)}
<div className={`border-t border-[var(--color-border)] p-3 ${sidebarOpen ? '' : 'flex flex-col items-center'}`}>
{authUser ? <SidebarUserCard user={authUser} collapsed={!sidebarOpen} /> : null}
<div className={`mb-1 ${sidebarOpen ? '' : 'mb-2'}`}>
<NavItem
active={false}
collapsed={!sidebarOpen}
label={isLoggingOut ? t('sidebar.loggingOut') : t('sidebar.logout')}
onClick={() => {
if (isLoggingOut) return
setIsLoggingOut(true)
void logout()
.catch((error) => {
addToast({
type: 'error',
message: error instanceof Error ? error.message : t('sidebar.logoutFailed'),
})
})
.finally(() => {
setIsLoggingOut(false)
})
}}
icon={<span className="material-symbols-outlined text-[18px]">logout</span>}
>
{isLoggingOut ? t('sidebar.loggingOut') : t('sidebar.logout')}
</NavItem>
</div>
<NavItem
active={activeTabId === SETTINGS_TAB_ID}
collapsed={!sidebarOpen}
label={t('sidebar.settings')}
onClick={() => useTabStore.getState().openTab(SETTINGS_TAB_ID, t('sidebar.settings'), 'settings')}
icon={<span className="material-symbols-outlined text-[18px]">settings</span>}
>
{t('sidebar.settings')}
</NavItem>
</div>
{contextMenu && sidebarOpen && (
<div
className="fixed z-50 min-w-[140px] rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] py-1"
style={{ left: contextMenu.x, top: contextMenu.y, boxShadow: 'var(--shadow-dropdown)' }}
>
<button
onClick={() => {
const session = sessions.find((s) => s.id === contextMenu.id)
handleStartRename(contextMenu.id, session?.title || '')
}}
className="w-full px-3 py-1.5 text-left text-xs text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-surface-hover)]"
>
{t('common.rename')}
</button>
<button
onClick={() => handleDelete(contextMenu.id)}
className="w-full px-3 py-1.5 text-left text-xs text-[var(--color-error)] transition-colors hover:bg-[var(--color-surface-hover)]"
>
{t('common.delete')}
</button>
</div>
)}
<ConfirmDialog
open={pendingDeleteSessionId !== null}
onClose={() => setPendingDeleteSessionId(null)}
onConfirm={confirmDelete}
title={t('common.delete')}
body={pendingDeleteSessionId ? t('sidebar.confirmDelete') : ''}
confirmLabel={t('common.delete')}
cancelLabel={t('common.cancel')}
confirmVariant="danger"
/>
</aside>
)
}
function groupByTime(sessions: SessionListItem[]): Map<TimeGroup, SessionListItem[]> {
const groups = new Map<TimeGroup, SessionListItem[]>()
const now = Date.now()
const oneDayAgo = now - 86400000
const threeDaysAgo = now - 3 * 86400000
const sevenDaysAgo = now - 7 * 86400000
for (const session of sessions) {
const ts = new Date(session.modifiedAt).getTime()
let group: TimeGroup
if (ts >= oneDayAgo) group = 'within1day'
else if (ts >= threeDaysAgo) group = 'within3days'
else if (ts >= sevenDaysAgo) group = 'within7days'
else group = 'older'
if (!groups.has(group)) groups.set(group, [])
groups.get(group)!.push(session)
}
return groups
}
function NavItem({
active,
collapsed,
label,
onClick,
icon,
children,
}: {
active: boolean
collapsed: boolean
label: string
onClick: () => void
icon: React.ReactNode
children: React.ReactNode
}) {
return (
<button
onClick={onClick}
aria-label={label}
title={collapsed ? label : undefined}
className={`
flex items-center transition-colors duration-200
${collapsed ? 'h-10 w-10 justify-center rounded-[var(--radius-md)] px-0 py-0' : 'w-full gap-2.5 rounded-[12px] px-3 py-2.5 text-sm'}
${active
? 'bg-[var(--color-sidebar-item-active)] font-medium text-[var(--color-text-primary)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-sidebar-item-hover)] hover:text-[var(--color-text-primary)]'
}
`}
>
<span className="flex h-5 w-5 flex-shrink-0 items-center justify-center">
{icon}
</span>
<span className={`sidebar-copy ${collapsed ? 'sidebar-copy--hidden' : 'sidebar-copy--visible'}`}>
{children}
</span>
</button>
)
}
// Logged-in user card in the bottom sidebar — gradient-filled avatar +
// name + email + role badge. Always visible so the user can see who they
// are signed in as without clicking anywhere. Clicking the card opens the
// account page on Heicode Manager (code.xinghanlab.com) in the system
// browser, so users can manage their balance / API keys / etc. Two
// layouts: expanded (full pill) and collapsed (small avatar circle with
// title tooltip).
const HEICODE_MANAGER_URL = 'https://code.xinghanlab.com/'
function openManagerProfile() {
// Tauri shell plugin in the bundled app; falls back to window.open in
// the dev/preview build where the plugin isn't injected.
import('@tauri-apps/plugin-shell')
.then((mod) => mod.open(HEICODE_MANAGER_URL))
.catch(() => window.open(HEICODE_MANAGER_URL, '_blank'))
}
/**
* Tiny SVG ring around the avatar showing balance health. The arc length
* equals `remainingFraction`; color mirrors BalanceBar's traffic-light
* thresholds (green ≥30%, amber ≥10%, red <10%). When balance is null
* we skip the ring entirely so a logged-out / pre-fetch state doesn't
* paint a stale color.
*/
function BalanceRing({ size, remainingFraction }: { size: number; remainingFraction: number | null }) {
if (remainingFraction === null) return null
const stroke = 2
const r = (size - stroke) / 2
const circumference = 2 * Math.PI * r
const clamped = Math.max(0, Math.min(1, remainingFraction))
const color =
clamped >= 0.3 ? 'var(--color-success)' :
clamped >= 0.1 ? 'var(--color-warning)' :
'var(--color-error)'
return (
<svg
className="pointer-events-none absolute inset-0"
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
aria-hidden
>
<circle
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
stroke={color}
strokeOpacity={0.18}
strokeWidth={stroke}
/>
<circle
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
stroke={color}
strokeWidth={stroke}
strokeLinecap="round"
strokeDasharray={`${circumference * clamped} ${circumference}`}
transform={`rotate(-90 ${size / 2} ${size / 2})`}
/>
</svg>
)
}
function SidebarUserCard({
user,
collapsed,
}: {
user: {
email?: string
displayName?: string
role?: string
userId?: string
}
collapsed: boolean
}) {
const name = user.displayName?.trim() || user.email?.trim() || 'You'
const initial = (name.slice(0, 1) || '?').toUpperCase()
const role = user.role?.trim().toLowerCase()
const roleBadge = role && role !== 'user' && role !== '1' ? role : null
const t = useTranslation()
const profileTitle = t('sidebar.userCard.openProfile')
const balanceRemainingFraction = useBalanceRemainingFraction()
if (collapsed) {
return (
<button
type="button"
onClick={openManagerProfile}
className="relative mb-2 flex h-9 w-9 cursor-pointer items-center justify-center rounded-full text-[12px] font-bold text-white transition-opacity hover:opacity-85"
style={{ backgroundImage: 'var(--gradient-brand-wordmark)' }}
title={`${profileTitle}\n${user.email || name}`}
>
{initial}
<BalanceRing size={36} remainingFraction={balanceRemainingFraction} />
</button>
)
}
return (
<button
type="button"
onClick={openManagerProfile}
title={profileTitle}
className="mb-2 flex w-full items-center gap-2.5 rounded-[12px] border px-2.5 py-2 text-left transition-colors hover:bg-[rgba(123,107,227,0.10)]"
style={{
borderColor: 'rgba(123,107,227,0.22)',
backgroundColor: 'rgba(123,107,227,0.06)',
}}
>
<span
className="relative flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full text-xs font-bold text-white"
style={{ backgroundImage: 'var(--gradient-brand-wordmark)' }}
>
{initial}
<BalanceRing size={32} remainingFraction={balanceRemainingFraction} />
</span>
<div className="min-w-0 flex-1 leading-tight">
<div className="truncate text-[12px] font-semibold text-[var(--color-text-primary)]">
{name}
</div>
{user.email && (
<div className="truncate text-[10px] text-[var(--color-text-tertiary)]">
{user.email}
</div>
)}
</div>
{roleBadge && (
<span
className="flex-shrink-0 rounded-full px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider"
style={{
backgroundColor: 'rgba(123,107,227,0.14)',
color: 'var(--color-primary)',
}}
>
{roleBadge}
</span>
)}
</button>
)
}
function formatRelativeTime(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime()
const min = Math.floor(diff / 60000)
if (min < 1) return 'now'
if (min < 60) return `${min}m`
const hr = Math.floor(min / 60)
if (hr < 24) return `${hr}h`
const day = Math.floor(hr / 24)
if (day < 30) return `${day}d`
return `${Math.floor(day / 30)}mo`
}
function PlusIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
)
}
function ClockIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
)
}
function SearchIcon() {
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="7" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
)
}
function SidebarToggleIcon({ collapsed }: { collapsed: boolean }) {
return (
<svg
width={collapsed ? 16 : 14}
height={collapsed ? 16 : 14}
viewBox="0 0 14 14"
fill="none"
className={`sidebar-toggle-icon ${collapsed ? 'sidebar-toggle-icon--collapsed' : 'sidebar-toggle-icon--open'}`}
aria-hidden="true"
>
<path
d={collapsed ? 'M5 3 9 7l-4 4' : 'M9 3 5 7l4 4'}
className="sidebar-toggle-chevron"
/>
</svg>
)
}