Files
openchamber/packages/ui/src/components/session/sidebar/projects/useSessionGrouping.ts
T

287 lines
12 KiB
TypeScript

import { matchesRankQuery } from '@/lib/search/fuzzySearch';
import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2';
import type { WorktreeMetadata } from '@/types/worktree';
import type { SessionGroup, SessionNode } from '../types';
import {
dedupeSessionsById,
getArchivedScopeKey,
normalizeForBranchComparison,
normalizePath,
} from '../utils';
import { getSessionLifecycleOrderValue } from '@/sync/session-ordering';
import { formatDirectoryName, formatPathForDisplay } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
import { getWorktreeFirstSeenAt } from './worktreeFirstSeen';
type Args = {
homeDirectory: string | null;
worktreeMetadata: Map<string, WorktreeMetadata>;
pinnedSessionIds: Set<string>;
sessionOrderRanks: ReadonlyMap<string, number>;
gitBranches: Map<string, string | null>;
isVSCode: boolean;
};
const isArchivedSession = (session: Session): boolean => Boolean(session.time?.archived);
export const useSessionGrouping = (args: Args) => {
const { t } = useI18n();
const buildGroupSearchText = React.useCallback((group: SessionGroup): string => {
return [group.label, group.branch ?? '', group.description ?? '', group.directory ?? ''].join(' ').toLowerCase();
}, []);
const buildSessionSearchText = React.useCallback((session: Session): string => {
const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null) ?? '';
const sessionTitle = (session.title || t('sessions.sidebar.session.untitled')).trim();
return `${sessionTitle} ${sessionDirectory}`.toLowerCase();
}, [t]);
const filterSessionNodesForSearch = React.useCallback(
(nodes: SessionNode[], query: string): SessionNode[] => {
if (!query) {
return nodes;
}
return nodes.flatMap((node) => {
const nodeMatches = matchesRankQuery([buildSessionSearchText(node.session)], query);
if (nodeMatches) {
return [node];
}
const filteredChildren = filterSessionNodesForSearch(node.children, query);
if (filteredChildren.length === 0) {
return [];
}
return [{ ...node, children: filteredChildren }];
});
},
[buildSessionSearchText],
);
const buildGroupedSessions = React.useCallback(
(
projectSessions: Session[],
projectRoot: string | null,
availableWorktrees: WorktreeMetadata[],
projectRootBranch: string | null,
projectIsRepo: boolean,
) => {
const normalizedProjectRoot = normalizePath(projectRoot ?? null);
// `orderSessionsByLifecycleScopes` owns lifecycle ordering before project
// ownership buckets are built. Dedupe retains that root/sibling order.
const sortedProjectSessions = dedupeSessionsById(projectSessions);
const sessionMap = new Map(sortedProjectSessions.map((session) => [session.id, session]));
const childrenMap = new Map<string, Session[]>();
sortedProjectSessions.forEach((session) => {
const parentID = (session as Session & { parentID?: string | null }).parentID;
if (!parentID) return;
const parentSession = sessionMap.get(parentID);
if (!parentSession || isArchivedSession(parentSession) !== isArchivedSession(session)) {
return;
}
const collection = childrenMap.get(parentID) ?? [];
collection.push(session);
childrenMap.set(parentID, collection);
});
const worktreeByPath = new Map<string, WorktreeMetadata>();
availableWorktrees.forEach((meta) => {
if (meta.path) {
const normalized = normalizePath(meta.path) ?? meta.path;
worktreeByPath.set(normalized, meta);
}
});
const getSessionWorktree = (session: Session): WorktreeMetadata | null => {
const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null);
const sessionWorktreeMeta = args.worktreeMetadata.get(session.id) ?? null;
if (sessionWorktreeMeta) return sessionWorktreeMeta;
if (sessionDirectory) {
const worktree = worktreeByPath.get(sessionDirectory) ?? null;
if (worktree && sessionDirectory !== normalizedProjectRoot) {
return worktree;
}
}
return null;
};
const claimedSessionIds = new Set<string>();
const buildProjectNode = (session: Session): SessionNode => {
claimedSessionIds.add(session.id);
const children = childrenMap.get(session.id) ?? [];
const childNodes: SessionNode[] = [];
for (const child of children) {
if (claimedSessionIds.has(child.id)) continue;
childNodes.push(buildProjectNode(child));
}
return { session, children: childNodes, worktree: getSessionWorktree(session) };
};
const rootCandidates = sortedProjectSessions.filter((session) => {
const parentID = (session as Session & { parentID?: string | null }).parentID;
if (!parentID) return true;
const parentSession = sessionMap.get(parentID);
if (!parentSession) return true;
return isArchivedSession(parentSession) !== isArchivedSession(session);
});
// A malformed cycle has no structural root. Start with normal roots,
// then expose each still-unclaimed component from its first input row.
const roots: SessionNode[] = [];
const addRoot = (session: Session): void => {
if (claimedSessionIds.has(session.id)) return;
roots.push(buildProjectNode(session));
};
rootCandidates.forEach(addRoot);
sortedProjectSessions.forEach(addRoot);
const groupedNodes = new Map<string, SessionNode[]>();
const archivedKey = '__archived__';
const getGroupKey = (session: Session) => {
if (session.time?.archived) return archivedKey;
// VS Code groups by open workspace, not by worktree: every non-archived
// session in a project belongs to that project's single (root) group.
// Worktrees aren't registered in VS Code, so the desktop directory-match
// below would otherwise dump these sessions into the archived bucket.
if (args.isVSCode) return normalizedProjectRoot ?? '__project_root__';
const metadataPath = normalizePath(args.worktreeMetadata.get(session.id)?.path ?? null);
const normalizedDir = metadataPath ?? resolveGlobalSessionDirectory(session);
if (!normalizedDir) return archivedKey;
if (normalizedDir !== normalizedProjectRoot && worktreeByPath.has(normalizedDir)) return normalizedDir;
if (normalizedDir === normalizedProjectRoot) return normalizedProjectRoot ?? '__project_root__';
return archivedKey;
};
roots.forEach((node) => {
const groupKey = getGroupKey(node.session);
if (!groupedNodes.has(groupKey)) groupedNodes.set(groupKey, []);
groupedNodes.get(groupKey)?.push(node);
});
const rootKey = normalizedProjectRoot ?? '__project_root__';
const groups: SessionGroup[] = [{
id: 'root',
label: (projectIsRepo && projectRootBranch && projectRootBranch !== 'HEAD')
? t('sessions.sidebar.grouping.projectRootWithBranch', { branch: projectRootBranch })
: t('sessions.sidebar.grouping.projectRoot'),
branch: projectRootBranch ?? null,
description: normalizedProjectRoot ? formatPathForDisplay(normalizedProjectRoot, args.homeDirectory) : null,
isMain: true,
isArchivedBucket: false,
worktree: null,
directory: normalizedProjectRoot,
folderScopeKey: normalizedProjectRoot,
sessions: groupedNodes.get(rootKey) ?? [],
}];
// Calculate display-order activity for each worktree.
const worktreeActivityInfo = new Map<string, { hasActiveSession: boolean; lastUpdatedAt: number }>();
availableWorktrees.forEach((meta) => {
const directory = normalizePath(meta.path) ?? meta.path;
const sessionsInWorktree = groupedNodes.get(directory) ?? [];
const hasActiveSession = sessionsInWorktree.length > 0;
// Lifecycle rank wins when present; timestamps seed bootstrap ordering.
const lastUpdatedAt = sessionsInWorktree.reduce((max, node) => {
const updatedAt = getSessionLifecycleOrderValue(node.session, args.sessionOrderRanks);
if (!Number.isFinite(updatedAt)) {
return max;
}
return Math.max(max, updatedAt);
}, 0);
worktreeActivityInfo.set(directory, { hasActiveSession, lastUpdatedAt });
});
// Sort populated worktrees by shared session activity, then empty ones by label.
const sortedWorktrees = [...availableWorktrees].sort((a, b) => {
const aDir = normalizePath(a.path) ?? a.path;
const bDir = normalizePath(b.path) ?? b.path;
const aInfo = worktreeActivityInfo.get(aDir) ?? { hasActiveSession: false, lastUpdatedAt: 0 };
const bInfo = worktreeActivityInfo.get(bDir) ?? { hasActiveSession: false, lastUpdatedAt: 0 };
// First priority: active status (active first)
if (aInfo.hasActiveSession !== bInfo.hasActiveSession) {
return aInfo.hasActiveSession ? -1 : 1;
}
// Second priority: for populated worktrees, sort by latest display activity.
if (aInfo.hasActiveSession && bInfo.hasActiveSession) {
return bInfo.lastUpdatedAt - aInfo.lastUpdatedAt;
}
// Third priority: for inactive worktrees, most recently discovered
// first (a worktree created mid-session surfaces at the top of the
// list; startup discovery ties and falls through to labels).
const aSeen = getWorktreeFirstSeenAt(a.path);
const bSeen = getWorktreeFirstSeenAt(b.path);
if (aSeen !== bSeen) {
return bSeen - aSeen;
}
// Fourth priority: sort by label (asc)
const aLabel = (a.label || a.branch || a.name || a.path || '').toLowerCase();
const bLabel = (b.label || b.branch || b.name || b.path || '').toLowerCase();
return aLabel.localeCompare(bLabel);
});
// VS Code groups strictly by open workspace — no per-worktree subgroups.
const worktreeGroups = args.isVSCode ? [] : sortedWorktrees;
worktreeGroups.forEach((meta) => {
const directory = normalizePath(meta.path) ?? meta.path;
const currentBranch = args.gitBranches.get(directory)?.trim() || null;
const metadataBranch = meta.branch?.trim() || null;
const shouldSyncLabelWithBranch = Boolean(
currentBranch && metadataBranch && meta.label && normalizeForBranchComparison(meta.label) === normalizeForBranchComparison(metadataBranch),
);
const label = shouldSyncLabelWithBranch
? currentBranch!
: (meta.label || meta.name || formatDirectoryName(directory, args.homeDirectory) || directory);
groups.push({
id: `worktree:${directory}`,
label,
branch: currentBranch || metadataBranch,
description: formatPathForDisplay(directory, args.homeDirectory),
isMain: false,
isArchivedBucket: false,
worktree: meta,
directory,
folderScopeKey: directory,
sessions: groupedNodes.get(directory) ?? [],
});
});
const archivedSessions = groupedNodes.get(archivedKey) ?? [];
if (archivedSessions.length > 0) {
groups.push({
id: 'archived',
label: t('sessions.sidebar.grouping.archived'),
branch: null,
description: t('sessions.sidebar.grouping.archivedDescription'),
isMain: false,
isArchivedBucket: true,
worktree: null,
directory: null,
folderScopeKey: !args.isVSCode && normalizedProjectRoot ? getArchivedScopeKey(normalizedProjectRoot) : null,
sessions: archivedSessions,
});
}
return groups;
},
[args.homeDirectory, args.worktreeMetadata, args.sessionOrderRanks, args.gitBranches, args.isVSCode, t],
);
return {
buildGroupSearchText,
buildSessionSearchText,
filterSessionNodesForSearch,
buildGroupedSessions,
};
};