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>
806 lines
33 KiB
TypeScript
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>
|
|
)
|
|
}
|