fix(sync): unify live session truth across chat and sidebar
This commit is contained in:
@@ -21,7 +21,7 @@ import { DiffIcon } from '@/components/icons/DiffIcon';
|
||||
import { useUIStore, type MainTab } from '@/stores/useUIStore';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useSession, useSessionMessagesResolved } from '@/sync/sync-context';
|
||||
import { useAllLiveSessions, useSession, useSessionMessagesResolved } from '@/sync/sync-context';
|
||||
import { getAllSyncSessions } from '@/sync/sync-refs';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore';
|
||||
@@ -640,6 +640,7 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
const currentSessionMessagesResolved = useSessionMessagesResolved(currentSessionId ?? '');
|
||||
const currentSyncedSession = useSession(currentSessionId ?? null);
|
||||
const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions);
|
||||
const liveSessions = useAllLiveSessions();
|
||||
const activeProject = useProjectsStore((state) => {
|
||||
if (!state.activeProjectId) {
|
||||
return null;
|
||||
@@ -940,13 +941,12 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
|
||||
const currentSessionLive = React.useMemo(() => {
|
||||
if (!currentSessionId) return null;
|
||||
// Resolve from the global sessions snapshot first (same source as sidebar).
|
||||
// Child-store lists are intentionally partial/truncated during bootstrap.
|
||||
return globalActiveSessions.find((s) => s.id === currentSessionId)
|
||||
return liveSessions.find((s) => s.id === currentSessionId)
|
||||
?? globalActiveSessions.find((s) => s.id === currentSessionId)
|
||||
?? currentSyncedSession
|
||||
?? getAllSyncSessions().find((s) => s.id === currentSessionId)
|
||||
?? null;
|
||||
}, [currentSessionId, currentSyncedSession, globalActiveSessions]);
|
||||
}, [currentSessionId, currentSyncedSession, globalActiveSessions, liveSessions]);
|
||||
|
||||
const lastResolvedSessionRef = React.useRef<{
|
||||
sessionId: string;
|
||||
|
||||
@@ -8,7 +8,7 @@ import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
import { sessionEvents } from '@/lib/sessionEvents';
|
||||
import { formatDirectoryName, cn } from '@/lib/utils';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useSidebarSessions, useAllSessionStatuses } from '@/sync/sync-context';
|
||||
import { useAllLiveSessions, useAllSessionStatuses } from '@/sync/sync-context';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useSync } from '@/sync/use-sync';
|
||||
import { useSessionPrefetch } from './sidebar/hooks/useSessionPrefetch';
|
||||
@@ -57,19 +57,14 @@ import {
|
||||
} from './sidebar/ConfirmDialogs';
|
||||
import { type SessionGroup, type SessionNode } from './sidebar/types';
|
||||
import {
|
||||
type ActiveNowEntry,
|
||||
addActiveNowSession,
|
||||
deriveActiveNowSessions,
|
||||
persistActiveNowEntries,
|
||||
pruneActiveNowEntries,
|
||||
readActiveNowEntries,
|
||||
deriveLiveActiveNowSessions,
|
||||
} from './sidebar/activitySections';
|
||||
import {
|
||||
compareSessionsByPinnedAndTime,
|
||||
formatProjectLabel,
|
||||
normalizePath,
|
||||
} from './sidebar/utils';
|
||||
import { refreshGlobalSessions, resolveGlobalSessionDirectory, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||
import { refreshGlobalSessions, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||
import { subscribeOpenchamberEvents } from '@/lib/openchamberEvents';
|
||||
@@ -115,44 +110,6 @@ interface SessionSidebarProps {
|
||||
showOnlyMainWorkspace?: boolean;
|
||||
}
|
||||
|
||||
type SessionStatusActivityBridgeProps = {
|
||||
safeStorage: Storage;
|
||||
setActiveNowEntries: React.Dispatch<React.SetStateAction<ActiveNowEntry[]>>;
|
||||
};
|
||||
|
||||
const SessionStatusActivityBridge: React.FC<SessionStatusActivityBridgeProps> = ({
|
||||
safeStorage,
|
||||
setActiveNowEntries,
|
||||
}) => {
|
||||
const globalSessionStatuses = useAllSessionStatuses();
|
||||
const sessionStatus = React.useMemo(
|
||||
() => new Map(Object.entries(globalSessionStatuses)),
|
||||
[globalSessionStatuses],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const nextStreamingIds = new Set<string>();
|
||||
sessionStatus.forEach((status, sessionId) => {
|
||||
if (status?.type === 'busy' || status?.type === 'retry') {
|
||||
nextStreamingIds.add(sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
if (nextStreamingIds.size > 0) {
|
||||
setActiveNowEntries((prev) => {
|
||||
const next = Array.from(nextStreamingIds).reduce((entries, sessionId) => addActiveNowSession(entries, sessionId), prev);
|
||||
if (next === prev) {
|
||||
return prev;
|
||||
}
|
||||
persistActiveNowEntries(safeStorage, next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [sessionStatus, safeStorage, setActiveNowEntries]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
mobileVariant = false,
|
||||
onSessionSelected,
|
||||
@@ -172,7 +129,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
() => new Map(),
|
||||
);
|
||||
const safeStorage = React.useMemo(() => getSafeStorage(), []);
|
||||
const [activeNowEntries, setActiveNowEntries] = React.useState(() => readActiveNowEntries(safeStorage));
|
||||
const [collapsedProjects, setCollapsedProjects] = React.useState<Set<string>>(new Set());
|
||||
|
||||
const [projectRepoStatus, setProjectRepoStatus] = React.useState<Map<string, boolean | null>>(new Map());
|
||||
@@ -307,10 +263,10 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
const gitBranches = useGitAllBranches();
|
||||
|
||||
const sync = useSync();
|
||||
const syncSessions = useSidebarSessions();
|
||||
const liveSessions = useAllLiveSessions();
|
||||
const liveSessionStatuses = useAllSessionStatuses();
|
||||
const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions);
|
||||
const archivedSessions = useGlobalSessionsStore((state) => state.archivedSessions);
|
||||
const hasLoadedGlobalSessions = useGlobalSessionsStore((state) => state.hasLoaded);
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const newSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
|
||||
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
|
||||
@@ -324,46 +280,34 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
const updateStore = useUpdateStore();
|
||||
|
||||
const sessions = React.useMemo(() => {
|
||||
if (!hasLoadedGlobalSessions) {
|
||||
return syncSessions;
|
||||
}
|
||||
|
||||
if (syncSessions.length === 0) {
|
||||
return globalActiveSessions;
|
||||
}
|
||||
|
||||
const syncedById = new Map(syncSessions.map((session) => [session.id, session]));
|
||||
const merged = globalActiveSessions.map((session) => syncedById.get(session.id) ?? session);
|
||||
const liveById = new Map(liveSessions.map((session) => [session.id, session]));
|
||||
const merged = globalActiveSessions.map((session) => liveById.get(session.id) ?? session);
|
||||
const seenIds = new Set(merged.map((session) => session.id));
|
||||
|
||||
syncSessions.forEach((session) => {
|
||||
liveSessions.forEach((session) => {
|
||||
if (seenIds.has(session.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionDirectory = resolveGlobalSessionDirectory(session);
|
||||
if (sessionDirectory && sessionDirectory === currentDirectory) {
|
||||
merged.push(session);
|
||||
}
|
||||
merged.push(session);
|
||||
});
|
||||
|
||||
return merged;
|
||||
}, [currentDirectory, globalActiveSessions, hasLoadedGlobalSessions, syncSessions]);
|
||||
}, [globalActiveSessions, liveSessions]);
|
||||
|
||||
const syncSessionStructureSignature = React.useMemo(
|
||||
() => syncSessions
|
||||
() => liveSessions
|
||||
.map((session) => {
|
||||
const directory = normalizePath((session as Session & { directory?: string | null }).directory ?? null) ?? '';
|
||||
return `${session.id}:${session.title ?? ''}:${session.time?.archived ? 1 : 0}:${directory}`;
|
||||
})
|
||||
.join('|'),
|
||||
[syncSessions],
|
||||
[liveSessions],
|
||||
);
|
||||
|
||||
const syncSessionsSnapshotRef = React.useRef<Session[]>(syncSessions);
|
||||
const syncSessionsSnapshotRef = React.useRef<Session[]>(liveSessions);
|
||||
React.useEffect(() => {
|
||||
syncSessionsSnapshotRef.current = syncSessions;
|
||||
}, [syncSessionStructureSignature, syncSessions]);
|
||||
syncSessionsSnapshotRef.current = liveSessions;
|
||||
}, [syncSessionStructureSignature, liveSessions]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -565,23 +509,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
[sortedSessions],
|
||||
);
|
||||
|
||||
const allKnownSessionsById = React.useMemo(() => {
|
||||
const next = new Map<string, Session>();
|
||||
[...sessions, ...archivedSessions].forEach((session) => {
|
||||
next.set(session.id, session);
|
||||
});
|
||||
return next;
|
||||
}, [sessions, archivedSessions]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const pruned = pruneActiveNowEntries(activeNowEntries, allKnownSessionsById);
|
||||
if (pruned.length === activeNowEntries.length && pruned.every((entry, index) => entry.sessionId === activeNowEntries[index]?.sessionId)) {
|
||||
return;
|
||||
}
|
||||
setActiveNowEntries(pruned);
|
||||
persistActiveNowEntries(safeStorage, pruned);
|
||||
}, [activeNowEntries, allKnownSessionsById, safeStorage]);
|
||||
|
||||
const childrenMap = React.useMemo(() => {
|
||||
const map = new Map<string, Session[]>();
|
||||
sortedSessions.forEach((session) => {
|
||||
@@ -1084,8 +1011,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
}, [projectSections, homeDirectory]);
|
||||
|
||||
const activeNowSessions = React.useMemo(
|
||||
() => deriveActiveNowSessions(activeNowEntries, new Map(sessions.map((session) => [session.id, session]))),
|
||||
[activeNowEntries, sessions],
|
||||
() => deriveLiveActiveNowSessions(sessions, liveSessionStatuses),
|
||||
[liveSessionStatuses, sessions],
|
||||
);
|
||||
|
||||
// Prefetch is wired below, after recentSessionIds is computed.
|
||||
@@ -1506,11 +1433,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<SessionStatusActivityBridge
|
||||
safeStorage={safeStorage}
|
||||
setActiveNowEntries={setActiveNowEntries}
|
||||
/>
|
||||
|
||||
<SidebarHeader
|
||||
hideDirectoryControls={hideDirectoryControls}
|
||||
handleOpenDirectoryDialog={handleOpenDirectoryDialog}
|
||||
|
||||
@@ -108,6 +108,24 @@ const getNodeChildSignature = (node: SessionNode): string => {
|
||||
.join('|');
|
||||
};
|
||||
|
||||
const treeContainsSessionId = (node: SessionNode, sessionId: string | null): boolean => {
|
||||
if (!sessionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (node.session.id === sessionId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const child of node.children) {
|
||||
if (treeContainsSessionId(child, sessionId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const areEqual = (prev: Props, next: Props): boolean => {
|
||||
const prevSession = prev.node.session;
|
||||
const nextSession = next.node.session;
|
||||
@@ -121,7 +139,13 @@ const areEqual = (prev: Props, next: Props): boolean => {
|
||||
if (prev.groupDirectory !== next.groupDirectory) return false;
|
||||
if (prev.projectId !== next.projectId) return false;
|
||||
if (prev.archivedBucket !== next.archivedBucket) return false;
|
||||
if ((prev.currentSessionId === prevSessionId) !== (next.currentSessionId === nextSessionId)) return false;
|
||||
if (prev.currentSessionId !== next.currentSessionId) {
|
||||
const prevActiveInTree = treeContainsSessionId(prev.node, prev.currentSessionId);
|
||||
const nextActiveInTree = treeContainsSessionId(next.node, next.currentSessionId);
|
||||
if (prevActiveInTree || nextActiveInTree) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (prev.pinnedSessionIds.has(prevSessionId) !== next.pinnedSessionIds.has(nextSessionId)) return false;
|
||||
if (prev.expandedParents.has(prevSessionId) !== next.expandedParents.has(nextSessionId)) return false;
|
||||
if (prev.hasSessionSearchQuery !== next.hasSessionSearchQuery) return false;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import type { SessionStatus } from '@opencode-ai/sdk/v2/client';
|
||||
|
||||
export const ACTIVE_NOW_STORAGE_KEY = 'oc.sessions.activeNow';
|
||||
export const ACTIVE_NOW_MAX_AGE_MS = 36 * 60 * 60 * 1000;
|
||||
@@ -104,4 +105,20 @@ export const deriveActiveNowSessions = (
|
||||
return sortSessionsByUpdated(sessions);
|
||||
};
|
||||
|
||||
export const deriveLiveActiveNowSessions = (
|
||||
sessions: Session[],
|
||||
statuses: Record<string, SessionStatus>,
|
||||
): Session[] => {
|
||||
const activeSessions = sessions.filter((session) => {
|
||||
if (isArchivedSession(session) || isSubtaskSession(session)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const status = statuses[session.id];
|
||||
return status?.type === 'busy' || status?.type === 'retry';
|
||||
});
|
||||
|
||||
return sortSessionsByUpdated(activeSessions);
|
||||
};
|
||||
|
||||
export const getSessionUpdatedAtMs = getSessionUpdatedAt;
|
||||
|
||||
Reference in New Issue
Block a user