Files
openchamber/packages/ui/src/components/session/sidebar/hooks/useSwitcherItems.ts
T
Bohdan Triapitsyn 89f7c37d60 fix: make session ordering follow activity lifecycle
Session lists now promote a conversation when it starts working and again when it settles, instead of reacting to every streaming timestamp update. This keeps ordering responsive without bringing back the sidebar churn removed by the recent performance work.

Apply the same user-visible order across Recent, project and worktree groups, session switchers, mobile navigation, widgets, the command palette, and the desktop tray. Preserve pinned priority, freeze timestamp fallback ordering, and keep child-session activity scoped to siblings under the same parent so it never moves the root conversation.

Seed reconnect snapshots without synthetic jumps, clear ephemeral ranks on deletion and runtime changes, and cover lifecycle transitions, mixed root/child trees, metadata-only updates, and project-group ordering with regression tests.
2026-07-23 15:45:53 +03:00

127 lines
4.7 KiB
TypeScript

import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2';
import { useGlobalSessionsStore, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useSessionPinnedStore } from '@/stores/useSessionPinnedStore';
import { useGitAllBranches } from '@/stores/useGitStore';
import type { SessionNode } from '../types';
import { isPathWithinProject } from '../utils';
import { compareSessionsByLifecycleOrder, useSessionOrderingStore } from '@/sync/session-ordering';
export type SwitcherItem = {
node: SessionNode;
projectId: string | null;
groupDirectory: string | null;
secondaryMeta: {
projectLabel?: string | null;
branchLabel?: string | null;
} | null;
};
const MAX_PARENT_SESSIONS = 7;
type SwitcherItemsOptions = {
scopeProjectId?: string | null;
};
const normalize = (value: string | null | undefined): string | null => {
if (!value) return null;
const replaced = value.replace(/\\/g, '/');
if (replaced === '/') return '/';
return replaced.length > 1 ? replaced.replace(/\/+$/, '') : replaced;
};
const formatProjectLabel = (project: { label?: string | null; path: string } | null): string | null => {
if (!project) return null;
const trimmed = project.label?.trim();
if (trimmed) return trimmed;
const segments = project.path.split(/[\\/]/).filter(Boolean);
return segments[segments.length - 1] ?? null;
};
export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions = {}): SwitcherItem[] => {
const { scopeProjectId = null } = options;
const activeSessions = useGlobalSessionsStore((state) => state.activeSessions);
const projects = useProjectsStore((state) => state.projects);
const pinnedSessionIds = useSessionPinnedStore((state) => state.ids);
const sessionOrderRanks = useSessionOrderingStore((state) => state.rankById);
const branchesByDirectory = useGitAllBranches();
const normalizedProjects = React.useMemo(
() => projects
.map((project) => ({ ...project, normalizedPath: normalize(project.path) }))
.filter((project) => project.normalizedPath),
[projects],
);
const findProjectForDirectory = React.useCallback(
(directory: string | null) => {
if (!directory) return null;
const matches = normalizedProjects
.filter((project) => isPathWithinProject(directory, project.normalizedPath))
.sort((a, b) => (b.normalizedPath?.length ?? 0) - (a.normalizedPath?.length ?? 0));
return matches[0] ?? null;
},
[normalizedProjects],
);
const items = React.useMemo<SwitcherItem[]>(() => {
if (!enabled) return [];
const childrenByParent = new Map<string, Session[]>();
for (const session of activeSessions) {
const parentId = (session as Session & { parentID?: string | null }).parentID;
if (!parentId) continue;
if (session.time?.archived) continue;
const bucket = childrenByParent.get(parentId);
if (bucket) {
bucket.push(session);
} else {
childrenByParent.set(parentId, [session]);
}
}
childrenByParent.forEach((list) => {
list.sort((a, b) => compareSessionsByLifecycleOrder(a, b, pinnedSessionIds, sessionOrderRanks));
});
const parents = activeSessions
.filter((session) => !session.time?.archived)
.filter((session) => !(session as Session & { parentID?: string | null }).parentID)
.filter((session) => {
if (!scopeProjectId) return true;
const directory = resolveGlobalSessionDirectory(session);
return findProjectForDirectory(directory)?.id === scopeProjectId;
})
.sort((a, b) => compareSessionsByLifecycleOrder(a, b, pinnedSessionIds, sessionOrderRanks))
.slice(0, MAX_PARENT_SESSIONS);
const buildNode = (session: Session): SessionNode => {
const childSessions = childrenByParent.get(session.id) ?? [];
return {
session,
children: childSessions.map((child) => buildNode(child)),
worktree: null,
};
};
return parents.map((session) => {
const directory = resolveGlobalSessionDirectory(session);
const matchedProject = findProjectForDirectory(directory);
const projectLabel = formatProjectLabel(matchedProject);
const branchLabel = directory ? branchesByDirectory.get(directory) ?? null : null;
return {
node: buildNode(session),
projectId: matchedProject?.id ?? null,
groupDirectory: directory,
secondaryMeta: {
projectLabel,
branchLabel: branchLabel && branchLabel !== projectLabel ? branchLabel : null,
},
};
});
}, [activeSessions, branchesByDirectory, enabled, findProjectForDirectory, pinnedSessionIds, scopeProjectId, sessionOrderRanks]);
return items;
};