Initial commit: restructure to flat layout with ui/ and web/ at root
This commit is contained in:
237
ui/src/components/session/sidebar/hooks/useSessionGrouping.ts
Normal file
237
ui/src/components/session/sidebar/hooks/useSessionGrouping.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
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 {
|
||||
compareSessionsByPinnedAndTime,
|
||||
dedupeSessionsById,
|
||||
getArchivedScopeKey,
|
||||
normalizeForBranchComparison,
|
||||
normalizePath,
|
||||
} from '../utils';
|
||||
import { formatDirectoryName, formatPathForDisplay } from '@/lib/utils';
|
||||
|
||||
type Args = {
|
||||
homeDirectory: string | null;
|
||||
worktreeMetadata: Map<string, WorktreeMetadata>;
|
||||
pinnedSessionIds: Set<string>;
|
||||
gitDirectories: Map<string, { status?: { current?: string | null } | null }>;
|
||||
};
|
||||
|
||||
export const useSessionGrouping = (args: Args) => {
|
||||
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 || 'Untitled Session').trim();
|
||||
return `${sessionTitle} ${sessionDirectory}`.toLowerCase();
|
||||
}, []);
|
||||
|
||||
const filterSessionNodesForSearch = React.useCallback(
|
||||
(nodes: SessionNode[], query: string): SessionNode[] => {
|
||||
if (!query) {
|
||||
return nodes;
|
||||
}
|
||||
|
||||
return nodes.flatMap((node) => {
|
||||
const nodeMatches = buildSessionSearchText(node.session).includes(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);
|
||||
const sortedProjectSessions = dedupeSessionsById(projectSessions)
|
||||
.sort((a, b) => compareSessionsByPinnedAndTime(a, b, args.pinnedSessionIds));
|
||||
|
||||
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 collection = childrenMap.get(parentID) ?? [];
|
||||
collection.push(session);
|
||||
childrenMap.set(parentID, collection);
|
||||
});
|
||||
childrenMap.forEach((list) => list.sort((a, b) => compareSessionsByPinnedAndTime(a, b, args.pinnedSessionIds)));
|
||||
|
||||
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 buildProjectNode = (session: Session): SessionNode => {
|
||||
const children = childrenMap.get(session.id) ?? [];
|
||||
return { session, children: children.map((child) => buildProjectNode(child)), worktree: getSessionWorktree(session) };
|
||||
};
|
||||
|
||||
const roots = sortedProjectSessions.filter((session) => {
|
||||
const parentID = (session as Session & { parentID?: string | null }).parentID;
|
||||
if (!parentID) return true;
|
||||
return !sessionMap.has(parentID);
|
||||
});
|
||||
|
||||
const groupedNodes = new Map<string, SessionNode[]>();
|
||||
const archivedKey = '__archived__';
|
||||
|
||||
const getGroupKey = (session: Session) => {
|
||||
if (session.time?.archived) return archivedKey;
|
||||
const metadataPath = normalizePath(args.worktreeMetadata.get(session.id)?.path ?? null);
|
||||
const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null);
|
||||
if (!metadataPath && !sessionDirectory) return archivedKey;
|
||||
const fallbackDirectory = normalizePath((session as Session & { project?: { worktree?: string | null } | null }).project?.worktree ?? null);
|
||||
const normalizedDir = metadataPath ?? sessionDirectory ?? fallbackDirectory;
|
||||
if (!normalizedDir) return archivedKey;
|
||||
if (normalizedDir !== normalizedProjectRoot && worktreeByPath.has(normalizedDir)) return normalizedDir;
|
||||
if (normalizedDir === normalizedProjectRoot) return normalizedProjectRoot ?? '__project_root__';
|
||||
return archivedKey;
|
||||
};
|
||||
|
||||
roots.forEach((session) => {
|
||||
const node = buildProjectNode(session);
|
||||
const groupKey = getGroupKey(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') ? `project root: ${projectRootBranch}` : 'project root',
|
||||
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 activity info for each worktree to determine sorting priority
|
||||
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;
|
||||
// Calculate the latest update time among all sessions in this worktree
|
||||
const lastUpdatedAt = sessionsInWorktree.reduce((max, node) => {
|
||||
const updatedAt = Number(node.session.time?.updated ?? node.session.time?.created ?? 0);
|
||||
if (!Number.isFinite(updatedAt)) {
|
||||
return max;
|
||||
}
|
||||
return Math.max(max, updatedAt);
|
||||
}, 0);
|
||||
|
||||
worktreeActivityInfo.set(directory, { hasActiveSession, lastUpdatedAt });
|
||||
});
|
||||
|
||||
// Sort worktrees: active first (by last updated desc), then inactive (by label asc)
|
||||
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 active worktrees, sort by last updated (desc)
|
||||
if (aInfo.hasActiveSession && bInfo.hasActiveSession) {
|
||||
return bInfo.lastUpdatedAt - aInfo.lastUpdatedAt;
|
||||
}
|
||||
|
||||
// Third priority: for inactive worktrees, 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);
|
||||
});
|
||||
|
||||
sortedWorktrees.forEach((meta) => {
|
||||
const directory = normalizePath(meta.path) ?? meta.path;
|
||||
const currentBranch = args.gitDirectories.get(directory)?.status?.current?.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) ?? [],
|
||||
});
|
||||
});
|
||||
|
||||
groups.push({
|
||||
id: 'archived',
|
||||
label: 'archived',
|
||||
branch: null,
|
||||
description: 'Archived and unassigned sessions',
|
||||
isMain: false,
|
||||
isArchivedBucket: true,
|
||||
worktree: null,
|
||||
directory: null,
|
||||
folderScopeKey: normalizedProjectRoot ? getArchivedScopeKey(normalizedProjectRoot) : null,
|
||||
sessions: groupedNodes.get(archivedKey) ?? [],
|
||||
});
|
||||
|
||||
return groups;
|
||||
},
|
||||
[args.homeDirectory, args.worktreeMetadata, args.pinnedSessionIds, args.gitDirectories],
|
||||
);
|
||||
|
||||
return {
|
||||
buildGroupSearchText,
|
||||
buildSessionSearchText,
|
||||
filterSessionNodesForSearch,
|
||||
buildGroupedSessions,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user