agent-ecosystem/src/renderer/components/team/TeamSessionsSection.tsx

286 lines
8.8 KiB
TypeScript

import { useCallback, useMemo } from 'react';
import { useAppTranslation } from '@features/localization/renderer';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store';
import { resolveProjectIdByPath } from '@renderer/utils/projectLookup';
import { formatSessionLabel } from '@renderer/utils/sessionTitleParser';
import { formatDistanceToNowStrict } from 'date-fns';
import {
AlertCircle,
Crown,
ExternalLink,
Filter,
FilterX,
Loader2,
MessageSquare,
Monitor,
} from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import type { Session } from '@renderer/types/data';
interface TeamSessionsSectionProps {
sessions: Session[];
sessionsLoading: boolean;
sessionsError: string | null;
leadSessionId?: string;
selectedSessionId: string | null;
onSelectSession: (sessionId: string | null) => void;
projectPath?: string;
}
export const TeamSessionsSection = ({
sessions,
sessionsLoading,
sessionsError,
leadSessionId,
selectedSessionId,
onSelectSession,
projectPath,
}: TeamSessionsSectionProps): React.JSX.Element => {
const { t } = useAppTranslation('team');
const { openTab, selectSession, projects, repositoryGroups } = useStore(
useShallow((s) => ({
openTab: s.openTab,
selectSession: s.selectSession,
projects: s.projects,
repositoryGroups: s.repositoryGroups,
}))
);
const projectId = useMemo(
() => resolveProjectIdByPath(projectPath, projects, repositoryGroups),
[projects, repositoryGroups, projectPath]
);
// Sort: lead session first, then by most recent
const sortedSessions = useMemo(() => {
if (!leadSessionId) return sessions;
return [...sessions].sort((a, b) => {
if (a.id === leadSessionId) return -1;
if (b.id === leadSessionId) return 1;
return b.createdAt - a.createdAt;
});
}, [sessions, leadSessionId]);
const handleSessionClick = useCallback(
(session: Session) => {
if (!projectId) return;
openTab(
{
type: 'session',
sessionId: session.id,
projectId,
label: formatSessionLabel(session.firstMessage),
},
{ forceNewTab: true }
);
selectSession(session.id);
},
[projectId, openTab, selectSession]
);
if (!projectPath) {
return (
<div className="py-6 text-center text-xs text-[var(--color-text-muted)]">
<Monitor size={20} className="mx-auto mb-2 opacity-40" />
{t('sessions.noProjectPath')}
<p className="mt-1 text-[10px] opacity-60">{t('sessions.provisioningHint')}</p>
</div>
);
}
if (!projectId) {
return (
<div className="py-6 text-center text-xs text-[var(--color-text-muted)]">
<AlertCircle size={20} className="mx-auto mb-2 opacity-40" />
{t('sessions.projectNotFound')}
<p className="mt-1 max-w-[260px] truncate text-[10px] opacity-60">{projectPath}</p>
</div>
);
}
if (sessionsLoading) {
return (
<div className="flex items-center justify-center gap-2 py-6 text-xs text-[var(--color-text-muted)]">
<Loader2 size={14} className="animate-spin" />
{t('sessions.loading')}
</div>
);
}
if (sessionsError) {
return (
<div className="flex items-center justify-center gap-2 py-6 text-xs text-red-400">
<AlertCircle size={14} />
{sessionsError}
</div>
);
}
if (sortedSessions.length === 0) {
return (
<div className="py-6 text-center text-xs text-[var(--color-text-muted)]">
<Monitor size={20} className="mx-auto mb-2 opacity-40" />
{t('sessions.empty')}
</div>
);
}
return (
<div className="space-y-1">
{selectedSessionId !== null && (
<button
type="button"
className="flex w-full items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-blue-600 transition-colors hover:bg-blue-500/10 dark:text-blue-400"
onClick={() => onSelectSession(null)}
>
<FilterX size={12} />
{t('sessions.showAllSessions')}
</button>
)}
{sortedSessions.map((session) => (
<SessionRow
key={session.id}
session={session}
isLead={session.id === leadSessionId}
isSelected={session.id === selectedSessionId}
onClick={() => handleSessionClick(session)}
onToggleFilter={() =>
onSelectSession(session.id === selectedSessionId ? null : session.id)
}
leadLabel={t('sessions.lead')}
removeFilterLabel={t('sessions.removeFilter')}
filterBySessionLabel={t('sessions.filterBySession')}
openSessionLabel={t('sessions.openSession')}
/>
))}
</div>
);
};
// ---------------------------------------------------------------------------
// Session row
// ---------------------------------------------------------------------------
interface SessionRowProps {
session: Session;
isLead: boolean;
isSelected: boolean;
onClick: () => void;
onToggleFilter: () => void;
leadLabel: string;
removeFilterLabel: string;
filterBySessionLabel: string;
openSessionLabel: string;
}
const SessionRow = ({
session,
isLead,
isSelected,
onClick,
onToggleFilter,
leadLabel,
removeFilterLabel,
filterBySessionLabel,
openSessionLabel,
}: SessionRowProps): React.JSX.Element => {
const timeAgo = formatShortTime(new Date(session.createdAt));
const label = formatSessionLabel(session.firstMessage);
return (
<div
className={`group flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-xs transition-colors hover:bg-[var(--color-surface-raised)] ${
isLead ? 'border border-blue-500/20 bg-blue-500/5' : ''
} ${isSelected ? 'bg-blue-500/10 ring-1 ring-blue-400/50' : ''}`}
>
{isLead && <Crown size={12} className="shrink-0 text-blue-400" />}
<button type="button" className="min-w-0 flex-1 text-left" onClick={onClick}>
<div className="flex items-center gap-1.5">
{session.isOngoing && (
<span className="size-1.5 shrink-0 animate-pulse rounded-full bg-green-400" />
)}
<span className="truncate text-[var(--color-text)]">{label}</span>
</div>
<div className="mt-0.5 flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
<span className="flex items-center gap-0.5">
<MessageSquare size={9} />
{session.messageCount}
</span>
<span style={{ opacity: 0.5 }}>·</span>
<span className="tabular-nums">{timeAgo}</span>
{isLead && (
<>
<span style={{ opacity: 0.5 }}>·</span>
<span className="text-blue-600 dark:text-blue-400">{leadLabel}</span>
</>
)}
</div>
</button>
<div className="flex shrink-0 items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={`rounded p-0.5 text-[var(--color-text-muted)] transition-opacity hover:text-blue-400 ${
isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
}`}
onClick={(e) => {
e.stopPropagation();
onToggleFilter();
}}
>
{isSelected ? <FilterX size={12} /> : <Filter size={12} />}
</button>
</TooltipTrigger>
<TooltipContent side="left">
{isSelected ? removeFilterLabel : filterBySessionLabel}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="rounded p-0.5 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:text-[var(--color-text)] group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
onClick();
}}
>
<ExternalLink size={12} />
</button>
</TooltipTrigger>
<TooltipContent side="left">{openSessionLabel}</TooltipContent>
</Tooltip>
</div>
</div>
);
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function formatShortTime(date: Date): string {
const distance = formatDistanceToNowStrict(date, { addSuffix: false });
return distance
.replace(' seconds', 's')
.replace(' second', 's')
.replace(' minutes', 'm')
.replace(' minute', 'm')
.replace(' hours', 'h')
.replace(' hour', 'h')
.replace(' days', 'd')
.replace(' day', 'd')
.replace(' weeks', 'w')
.replace(' week', 'w')
.replace(' months', 'mo')
.replace(' month', 'mo')
.replace(' years', 'y')
.replace(' year', 'y');
}