perf(ui): streamline session sidebar state
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
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.pinnedSessionIds, args.sessionOrderRanks, args.gitBranches, args.isVSCode, t],
|
||||
);
|
||||
|
||||
return {
|
||||
buildGroupSearchText,
|
||||
buildSessionSearchText,
|
||||
filterSessionNodesForSearch,
|
||||
buildGroupedSessions,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user