import { deriveTitle } from "@/lib/format"; import type { ChatSummary, SidebarSortMode } from "@/lib/types"; import { normalizeWorkspacePath, projectNameFromPath, sameWorkspacePath } from "@/lib/workspace"; export const COLLAPSED_CHATS_VISIBLE_COUNT = 8; export interface SessionGroup { id: string; label: string; sessions: ChatSummary[]; kind?: "project"; projectPath?: string; projectKey?: string; updatedAt?: string | null; } export interface ChatGroupLabels { pinned: string; all: string; today: string; yesterday: string; earlier: string; archived: string; projects: string; fallbackTitle: string; } export interface ChatGroupingOptions { pinnedKeys: string[]; archivedKeys: string[]; titleOverrides: Record; projectNameOverrides: Record; showArchived: boolean; sort: SidebarSortMode; defaultWorkspacePath?: string | null; } export function groupSessions( sessions: ChatSummary[], labels: ChatGroupLabels, options: ChatGroupingOptions, ): SessionGroup[] { if (sessions.some((session) => session.workspaceScope?.project_path)) { return groupSessionsByProject(sessions, labels, options); } const now = new Date(); const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000; const buckets = new Map(); const pinned = new Set(options.pinnedKeys); const archived = new Set(options.archivedKeys); const pinnedSessions: ChatSummary[] = []; const archivedSessions: ChatSummary[] = []; const normalSessions: ChatSummary[] = []; for (const session of sessions) { if (archived.has(session.key)) { if (options.showArchived) archivedSessions.push(session); continue; } if (pinned.has(session.key)) { pinnedSessions.push(session); continue; } if (options.sort === "title_asc") { normalSessions.push(session); continue; } const timestamp = Date.parse(session.updatedAt ?? session.createdAt ?? ""); const label = Number.isFinite(timestamp) && timestamp >= startOfToday ? labels.today : Number.isFinite(timestamp) && timestamp >= startOfYesterday ? labels.yesterday : labels.earlier; const bucket = buckets.get(label) ?? []; bucket.push(session); buckets.set(label, bucket); } const groups: SessionGroup[] = [labels.today, labels.yesterday, labels.earlier] .map((label) => ({ id: `date:${label}`, label, sessions: sortSessions( buckets.get(label) ?? [], options.sort, options.titleOverrides, ), })) .filter((group) => group.sessions.length > 0); if (options.sort === "title_asc" && normalSessions.length) { groups.push({ id: "date:all", label: labels.all, sessions: sortSessions( normalSessions, options.sort, options.titleOverrides, ), }); } if (pinnedSessions.length) { groups.unshift({ id: "pinned", label: labels.pinned, sessions: sortSessions( pinnedSessions, options.sort, options.titleOverrides, ), }); } if (archivedSessions.length) { groups.push({ id: "archived", label: labels.archived, sessions: sortSessions( archivedSessions, options.sort, options.titleOverrides, ), }); } return groups; } export function limitGroups( groups: SessionGroup[], limit: number, activeKey: string | null, collapsedGroups: Record, ): SessionGroup[] { let remaining = Math.max(0, limit); let activeVisible = !activeKey; const out: SessionGroup[] = []; for (const group of groups) { if (isCollapsedProject(group, collapsedGroups)) { out.push({ ...group, sessions: [] }); continue; } const visible = remaining > 0 ? group.sessions.slice(0, remaining) : []; remaining -= visible.length; if (activeKey && visible.some((session) => session.key === activeKey)) { activeVisible = true; } if (visible.length > 0) { out.push({ ...group, sessions: visible }); } } if (activeVisible || !activeKey) return out; for (const group of groups) { if (isCollapsedProject(group, collapsedGroups)) continue; const active = group.sessions.find((session) => session.key === activeKey); if (!active) continue; const existing = out.find((item) => item.id === group.id); if (existing) { existing.sessions = [...existing.sessions, active]; } else { out.push({ ...group, sessions: [active] }); } return out; } return out; } export function isCollapsedProject( group: SessionGroup, collapsedGroups: Record, ): boolean { return group.kind === "project" && Boolean(collapsedGroups[group.id]); } export function isFoldableChatsGroup(group: SessionGroup): boolean { return group.id === "workspace:chats" || group.id === "date:all"; } export function isFoldedChatsGroup( group: SessionGroup, collapsedGroups: Record, ): boolean { return ( isFoldableChatsGroup(group) && group.sessions.length > COLLAPSED_CHATS_VISIBLE_COUNT && collapsedGroups[group.id] !== false ); } export function visibleSessionsForGroup( group: SessionGroup, activeKey: string | null, collapsedGroups: Record, ): ChatSummary[] { if (!isFoldedChatsGroup(group, collapsedGroups)) { return group.sessions; } const visible = group.sessions.slice(0, COLLAPSED_CHATS_VISIBLE_COUNT); if (!activeKey || visible.some((session) => session.key === activeKey)) { return visible; } const active = group.sessions.find((session) => session.key === activeKey); return active ? [...visible, active] : visible; } export function displayTitle( session: ChatSummary, titleOverrides: Record, fallbackTitle: string, ): string { return ( titleOverrides[session.key]?.trim() || session.title?.trim() || deriveTitle(session.preview, fallbackTitle) ); } function groupSessionsByProject( sessions: ChatSummary[], labels: Pick, options: ChatGroupingOptions, ): SessionGroup[] { const archived = new Set(options.archivedKeys); const conversations: ChatSummary[] = []; const buckets = new Map(); for (const session of sessions) { if (archived.has(session.key) && !options.showArchived) { continue; } const scope = session.workspaceScope; const path = scope?.project_path || ""; if (!path || sameWorkspacePath(path, options.defaultWorkspacePath)) { conversations.push(session); continue; } const key = normalizeWorkspacePath(path); const label = options.projectNameOverrides[key]?.trim() || scope?.project_name?.trim() || projectNameFromPath(path); const bucket = buckets.get(key) ?? { path, label, sessions: [], updatedAt: null, }; bucket.sessions.push(session); const candidate = session.updatedAt ?? session.createdAt ?? null; if (isNewerDate(candidate, bucket.updatedAt)) { bucket.updatedAt = candidate; } buckets.set(key, bucket); } const pinned = new Set(options.pinnedKeys); const groups: SessionGroup[] = Array.from(buckets.entries()).map(([key, bucket]) => ({ id: `project:${key}`, label: bucket.label, kind: "project" as const, projectPath: bucket.path, projectKey: key, updatedAt: bucket.updatedAt, sessions: sortProjectSessions( bucket.sessions, options.sort, options.titleOverrides, pinned, archived, ), })); if (conversations.length) { const chatsUpdatedAt = conversations.reduce( (best, s) => { const candidate = s.updatedAt ?? s.createdAt ?? null; return isNewerDate(candidate, best) ? candidate : best; }, null, ); groups.push({ id: "workspace:chats", label: labels.all, updatedAt: chatsUpdatedAt, sessions: sortProjectSessions( conversations, options.sort, options.titleOverrides, pinned, archived, ), }); } groups.sort((a, b) => { const timeOrder = dateToTime(b.updatedAt) - dateToTime(a.updatedAt); if (timeOrder !== 0) return timeOrder; return a.label.localeCompare(b.label, "en", { numeric: true, sensitivity: "base", }); }); return groups; } function sortProjectSessions( sessions: ChatSummary[], sort: SidebarSortMode, titleOverrides: Record, pinned: Set, archived: Set, ): ChatSummary[] { return sortSessions(sessions, sort, titleOverrides).sort((a, b) => { const pinOrder = Number(pinned.has(b.key)) - Number(pinned.has(a.key)); if (pinOrder !== 0) return pinOrder; const archiveOrder = Number(archived.has(a.key)) - Number(archived.has(b.key)); if (archiveOrder !== 0) return archiveOrder; return 0; }); } function sortSessions( sessions: ChatSummary[], sort: SidebarSortMode, titleOverrides: Record, ): ChatSummary[] { const copy = [...sessions]; copy.sort((a, b) => { if (sort === "title_asc") { const titleOrder = titleForSort(a, titleOverrides).localeCompare( titleForSort(b, titleOverrides), "en", { numeric: true, sensitivity: "base" }, ); if (titleOrder !== 0) return titleOrder; return sessionTime(b, "updatedAt") - sessionTime(a, "updatedAt"); } const aTime = sessionTime(a, sort === "created_desc" ? "createdAt" : "updatedAt"); const bTime = sessionTime(b, sort === "created_desc" ? "createdAt" : "updatedAt"); return bTime - aTime; }); return copy; } function isNewerDate(a: string | null, b: string | null): boolean { return dateToTime(a) > dateToTime(b); } function dateToTime(value: string | null | undefined): number { const ts = Date.parse(value ?? ""); return Number.isFinite(ts) ? ts : 0; } function titleForSort( session: ChatSummary, titleOverrides: Record, ): string { return ( titleOverrides[session.key]?.trim() || session.title?.trim() || deriveTitle(session.preview, "new chat") ).toLocaleLowerCase("en"); } function sessionTime(session: ChatSummary, field: "createdAt" | "updatedAt"): number { const ts = Date.parse(session[field] ?? ""); return Number.isFinite(ts) ? ts : 0; }