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 { useUIStore, type MainTab } from '@/stores/useUIStore';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
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 { getAllSyncSessions } from '@/sync/sync-refs';
|
||||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||||
import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore';
|
import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore';
|
||||||
@@ -640,6 +640,7 @@ export const Header: React.FC<HeaderProps> = ({
|
|||||||
const currentSessionMessagesResolved = useSessionMessagesResolved(currentSessionId ?? '');
|
const currentSessionMessagesResolved = useSessionMessagesResolved(currentSessionId ?? '');
|
||||||
const currentSyncedSession = useSession(currentSessionId ?? null);
|
const currentSyncedSession = useSession(currentSessionId ?? null);
|
||||||
const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions);
|
const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions);
|
||||||
|
const liveSessions = useAllLiveSessions();
|
||||||
const activeProject = useProjectsStore((state) => {
|
const activeProject = useProjectsStore((state) => {
|
||||||
if (!state.activeProjectId) {
|
if (!state.activeProjectId) {
|
||||||
return null;
|
return null;
|
||||||
@@ -940,13 +941,12 @@ export const Header: React.FC<HeaderProps> = ({
|
|||||||
|
|
||||||
const currentSessionLive = React.useMemo(() => {
|
const currentSessionLive = React.useMemo(() => {
|
||||||
if (!currentSessionId) return null;
|
if (!currentSessionId) return null;
|
||||||
// Resolve from the global sessions snapshot first (same source as sidebar).
|
return liveSessions.find((s) => s.id === currentSessionId)
|
||||||
// Child-store lists are intentionally partial/truncated during bootstrap.
|
?? globalActiveSessions.find((s) => s.id === currentSessionId)
|
||||||
return globalActiveSessions.find((s) => s.id === currentSessionId)
|
|
||||||
?? currentSyncedSession
|
?? currentSyncedSession
|
||||||
?? getAllSyncSessions().find((s) => s.id === currentSessionId)
|
?? getAllSyncSessions().find((s) => s.id === currentSessionId)
|
||||||
?? null;
|
?? null;
|
||||||
}, [currentSessionId, currentSyncedSession, globalActiveSessions]);
|
}, [currentSessionId, currentSyncedSession, globalActiveSessions, liveSessions]);
|
||||||
|
|
||||||
const lastResolvedSessionRef = React.useRef<{
|
const lastResolvedSessionRef = React.useRef<{
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
|||||||
import { sessionEvents } from '@/lib/sessionEvents';
|
import { sessionEvents } from '@/lib/sessionEvents';
|
||||||
import { formatDirectoryName, cn } from '@/lib/utils';
|
import { formatDirectoryName, cn } from '@/lib/utils';
|
||||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
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 { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
import { useSync } from '@/sync/use-sync';
|
import { useSync } from '@/sync/use-sync';
|
||||||
import { useSessionPrefetch } from './sidebar/hooks/useSessionPrefetch';
|
import { useSessionPrefetch } from './sidebar/hooks/useSessionPrefetch';
|
||||||
@@ -57,19 +57,14 @@ import {
|
|||||||
} from './sidebar/ConfirmDialogs';
|
} from './sidebar/ConfirmDialogs';
|
||||||
import { type SessionGroup, type SessionNode } from './sidebar/types';
|
import { type SessionGroup, type SessionNode } from './sidebar/types';
|
||||||
import {
|
import {
|
||||||
type ActiveNowEntry,
|
deriveLiveActiveNowSessions,
|
||||||
addActiveNowSession,
|
|
||||||
deriveActiveNowSessions,
|
|
||||||
persistActiveNowEntries,
|
|
||||||
pruneActiveNowEntries,
|
|
||||||
readActiveNowEntries,
|
|
||||||
} from './sidebar/activitySections';
|
} from './sidebar/activitySections';
|
||||||
import {
|
import {
|
||||||
compareSessionsByPinnedAndTime,
|
compareSessionsByPinnedAndTime,
|
||||||
formatProjectLabel,
|
formatProjectLabel,
|
||||||
normalizePath,
|
normalizePath,
|
||||||
} from './sidebar/utils';
|
} from './sidebar/utils';
|
||||||
import { refreshGlobalSessions, resolveGlobalSessionDirectory, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
import { refreshGlobalSessions, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||||
import { subscribeOpenchamberEvents } from '@/lib/openchamberEvents';
|
import { subscribeOpenchamberEvents } from '@/lib/openchamberEvents';
|
||||||
@@ -115,44 +110,6 @@ interface SessionSidebarProps {
|
|||||||
showOnlyMainWorkspace?: boolean;
|
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> = ({
|
export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||||
mobileVariant = false,
|
mobileVariant = false,
|
||||||
onSessionSelected,
|
onSessionSelected,
|
||||||
@@ -172,7 +129,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
() => new Map(),
|
() => new Map(),
|
||||||
);
|
);
|
||||||
const safeStorage = React.useMemo(() => getSafeStorage(), []);
|
const safeStorage = React.useMemo(() => getSafeStorage(), []);
|
||||||
const [activeNowEntries, setActiveNowEntries] = React.useState(() => readActiveNowEntries(safeStorage));
|
|
||||||
const [collapsedProjects, setCollapsedProjects] = React.useState<Set<string>>(new Set());
|
const [collapsedProjects, setCollapsedProjects] = React.useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const [projectRepoStatus, setProjectRepoStatus] = React.useState<Map<string, boolean | null>>(new Map());
|
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 gitBranches = useGitAllBranches();
|
||||||
|
|
||||||
const sync = useSync();
|
const sync = useSync();
|
||||||
const syncSessions = useSidebarSessions();
|
const liveSessions = useAllLiveSessions();
|
||||||
|
const liveSessionStatuses = useAllSessionStatuses();
|
||||||
const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions);
|
const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions);
|
||||||
const archivedSessions = useGlobalSessionsStore((state) => state.archivedSessions);
|
const archivedSessions = useGlobalSessionsStore((state) => state.archivedSessions);
|
||||||
const hasLoadedGlobalSessions = useGlobalSessionsStore((state) => state.hasLoaded);
|
|
||||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||||
const newSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
|
const newSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
|
||||||
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
|
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
|
||||||
@@ -324,46 +280,34 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
const updateStore = useUpdateStore();
|
const updateStore = useUpdateStore();
|
||||||
|
|
||||||
const sessions = React.useMemo(() => {
|
const sessions = React.useMemo(() => {
|
||||||
if (!hasLoadedGlobalSessions) {
|
const liveById = new Map(liveSessions.map((session) => [session.id, session]));
|
||||||
return syncSessions;
|
const merged = globalActiveSessions.map((session) => liveById.get(session.id) ?? session);
|
||||||
}
|
|
||||||
|
|
||||||
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 seenIds = new Set(merged.map((session) => session.id));
|
const seenIds = new Set(merged.map((session) => session.id));
|
||||||
|
|
||||||
syncSessions.forEach((session) => {
|
liveSessions.forEach((session) => {
|
||||||
if (seenIds.has(session.id)) {
|
if (seenIds.has(session.id)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
merged.push(session);
|
||||||
const sessionDirectory = resolveGlobalSessionDirectory(session);
|
|
||||||
if (sessionDirectory && sessionDirectory === currentDirectory) {
|
|
||||||
merged.push(session);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return merged;
|
return merged;
|
||||||
}, [currentDirectory, globalActiveSessions, hasLoadedGlobalSessions, syncSessions]);
|
}, [globalActiveSessions, liveSessions]);
|
||||||
|
|
||||||
const syncSessionStructureSignature = React.useMemo(
|
const syncSessionStructureSignature = React.useMemo(
|
||||||
() => syncSessions
|
() => liveSessions
|
||||||
.map((session) => {
|
.map((session) => {
|
||||||
const directory = normalizePath((session as Session & { directory?: string | null }).directory ?? null) ?? '';
|
const directory = normalizePath((session as Session & { directory?: string | null }).directory ?? null) ?? '';
|
||||||
return `${session.id}:${session.title ?? ''}:${session.time?.archived ? 1 : 0}:${directory}`;
|
return `${session.id}:${session.title ?? ''}:${session.time?.archived ? 1 : 0}:${directory}`;
|
||||||
})
|
})
|
||||||
.join('|'),
|
.join('|'),
|
||||||
[syncSessions],
|
[liveSessions],
|
||||||
);
|
);
|
||||||
|
|
||||||
const syncSessionsSnapshotRef = React.useRef<Session[]>(syncSessions);
|
const syncSessionsSnapshotRef = React.useRef<Session[]>(liveSessions);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
syncSessionsSnapshotRef.current = syncSessions;
|
syncSessionsSnapshotRef.current = liveSessions;
|
||||||
}, [syncSessionStructureSignature, syncSessions]);
|
}, [syncSessionStructureSignature, liveSessions]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -565,23 +509,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
[sortedSessions],
|
[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 childrenMap = React.useMemo(() => {
|
||||||
const map = new Map<string, Session[]>();
|
const map = new Map<string, Session[]>();
|
||||||
sortedSessions.forEach((session) => {
|
sortedSessions.forEach((session) => {
|
||||||
@@ -1084,8 +1011,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
}, [projectSections, homeDirectory]);
|
}, [projectSections, homeDirectory]);
|
||||||
|
|
||||||
const activeNowSessions = React.useMemo(
|
const activeNowSessions = React.useMemo(
|
||||||
() => deriveActiveNowSessions(activeNowEntries, new Map(sessions.map((session) => [session.id, session]))),
|
() => deriveLiveActiveNowSessions(sessions, liveSessionStatuses),
|
||||||
[activeNowEntries, sessions],
|
[liveSessionStatuses, sessions],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Prefetch is wired below, after recentSessionIds is computed.
|
// Prefetch is wired below, after recentSessionIds is computed.
|
||||||
@@ -1506,11 +1433,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<SessionStatusActivityBridge
|
|
||||||
safeStorage={safeStorage}
|
|
||||||
setActiveNowEntries={setActiveNowEntries}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SidebarHeader
|
<SidebarHeader
|
||||||
hideDirectoryControls={hideDirectoryControls}
|
hideDirectoryControls={hideDirectoryControls}
|
||||||
handleOpenDirectoryDialog={handleOpenDirectoryDialog}
|
handleOpenDirectoryDialog={handleOpenDirectoryDialog}
|
||||||
|
|||||||
@@ -108,6 +108,24 @@ const getNodeChildSignature = (node: SessionNode): string => {
|
|||||||
.join('|');
|
.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 areEqual = (prev: Props, next: Props): boolean => {
|
||||||
const prevSession = prev.node.session;
|
const prevSession = prev.node.session;
|
||||||
const nextSession = next.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.groupDirectory !== next.groupDirectory) return false;
|
||||||
if (prev.projectId !== next.projectId) return false;
|
if (prev.projectId !== next.projectId) return false;
|
||||||
if (prev.archivedBucket !== next.archivedBucket) 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.pinnedSessionIds.has(prevSessionId) !== next.pinnedSessionIds.has(nextSessionId)) return false;
|
||||||
if (prev.expandedParents.has(prevSessionId) !== next.expandedParents.has(nextSessionId)) return false;
|
if (prev.expandedParents.has(prevSessionId) !== next.expandedParents.has(nextSessionId)) return false;
|
||||||
if (prev.hasSessionSearchQuery !== next.hasSessionSearchQuery) return false;
|
if (prev.hasSessionSearchQuery !== next.hasSessionSearchQuery) return false;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Session } from '@opencode-ai/sdk/v2';
|
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_STORAGE_KEY = 'oc.sessions.activeNow';
|
||||||
export const ACTIVE_NOW_MAX_AGE_MS = 36 * 60 * 60 * 1000;
|
export const ACTIVE_NOW_MAX_AGE_MS = 36 * 60 * 60 * 1000;
|
||||||
@@ -104,4 +105,20 @@ export const deriveActiveNowSessions = (
|
|||||||
return sortSessionsByUpdated(sessions);
|
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;
|
export const getSessionUpdatedAtMs = getSessionUpdatedAt;
|
||||||
|
|||||||
@@ -109,6 +109,46 @@ const upsertSessionIntoList = (sessions: Session[], session: Session): Session[]
|
|||||||
return next;
|
return next;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mergeSessionLists = (existing: Session[], incoming?: Session[]): Session[] => {
|
||||||
|
if (!incoming || incoming.length === 0) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.length === 0) {
|
||||||
|
return incoming;
|
||||||
|
}
|
||||||
|
|
||||||
|
const byId = new Map(existing.map((session) => [session.id, session]));
|
||||||
|
incoming.forEach((session) => {
|
||||||
|
byId.set(session.id, session);
|
||||||
|
});
|
||||||
|
|
||||||
|
const ordered: Session[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
existing.forEach((session) => {
|
||||||
|
const next = byId.get(session.id);
|
||||||
|
if (!next) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ordered.push(next);
|
||||||
|
seen.add(session.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
incoming.forEach((session) => {
|
||||||
|
if (seen.has(session.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = byId.get(session.id);
|
||||||
|
if (next) {
|
||||||
|
ordered.push(next);
|
||||||
|
seen.add(session.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return ordered;
|
||||||
|
};
|
||||||
|
|
||||||
const applySnapshot = (
|
const applySnapshot = (
|
||||||
state: GlobalSessionsState,
|
state: GlobalSessionsState,
|
||||||
activeSessions: Session[],
|
activeSessions: Session[],
|
||||||
@@ -172,15 +212,16 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
|
|||||||
listGlobalSessionPages(sdk, { archived: true, pageSize: PAGE_SIZE }),
|
listGlobalSessionPages(sdk, { archived: true, pageSize: PAGE_SIZE }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const fallbackSnapshot = mergeSessionLists(current.activeSessions, fallbackActive);
|
||||||
const nextActiveSessions = activeResult.status === 'fulfilled'
|
const nextActiveSessions = activeResult.status === 'fulfilled'
|
||||||
? activeResult.value
|
? activeResult.value
|
||||||
: (fallbackActive ?? current.activeSessions);
|
: fallbackSnapshot;
|
||||||
const nextArchivedSessions = archivedResult.status === 'fulfilled'
|
const nextArchivedSessions = archivedResult.status === 'fulfilled'
|
||||||
? archivedResult.value
|
? archivedResult.value
|
||||||
: current.archivedSessions;
|
: current.archivedSessions;
|
||||||
|
|
||||||
if (activeResult.status === 'rejected') {
|
if (activeResult.status === 'rejected') {
|
||||||
console.warn('[GlobalSessions] Failed to load active sessions, using fallback:', activeResult.reason);
|
console.warn('[GlobalSessions] Failed to load active sessions, preserving existing snapshot with fallback merge:', activeResult.reason);
|
||||||
}
|
}
|
||||||
if (archivedResult.status === 'rejected') {
|
if (archivedResult.status === 'rejected') {
|
||||||
console.warn('[GlobalSessions] Failed to load archived sessions, preserving current snapshot:', archivedResult.reason);
|
console.warn('[GlobalSessions] Failed to load archived sessions, preserving current snapshot:', archivedResult.reason);
|
||||||
@@ -189,7 +230,7 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
|
|||||||
set((state) => applySnapshot(state, nextActiveSessions, nextArchivedSessions, 'ready'));
|
set((state) => applySnapshot(state, nextActiveSessions, nextArchivedSessions, 'ready'));
|
||||||
return { activeSessions: nextActiveSessions, archivedSessions: nextArchivedSessions };
|
return { activeSessions: nextActiveSessions, archivedSessions: nextArchivedSessions };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const nextActiveSessions = fallbackActive ?? current.activeSessions;
|
const nextActiveSessions = mergeSessionLists(current.activeSessions, fallbackActive);
|
||||||
const nextArchivedSessions = current.archivedSessions;
|
const nextArchivedSessions = current.archivedSessions;
|
||||||
console.warn('[GlobalSessions] Failed to load sessions, using fallback snapshot:', error);
|
console.warn('[GlobalSessions] Failed to load sessions, using fallback snapshot:', error);
|
||||||
set((state) => applySnapshot(state, nextActiveSessions, nextArchivedSessions, 'error'));
|
set((state) => applySnapshot(state, nextActiveSessions, nextArchivedSessions, 'error'));
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ There are **two distinct session data scopes** in the UI:
|
|||||||
- global archived sessions
|
- global archived sessions
|
||||||
- active sessions indexed by directory
|
- active sessions indexed by directory
|
||||||
|
|
||||||
These two scopes are intentionally different.
|
These two scopes are intentionally different, but they are no longer equal peers for live UI truth.
|
||||||
|
|
||||||
### Why both exist
|
### Why both exist
|
||||||
|
|
||||||
@@ -34,7 +34,8 @@ The directory-scoped sync stores are **not** a complete global view.
|
|||||||
So:
|
So:
|
||||||
|
|
||||||
- Use the **directory sync stores** for per-directory live session/message state
|
- Use the **directory sync stores** for per-directory live session/message state
|
||||||
- Use the **global sessions store** for sidebar/retention global session lists
|
- Use the **global sessions store** for cold/global session coverage (especially archived pages and unopened directories)
|
||||||
|
- Use **aggregated child-store snapshots** for live session/status truth across already initialized directories
|
||||||
|
|
||||||
## Ownership map
|
## Ownership map
|
||||||
|
|
||||||
@@ -62,12 +63,22 @@ Examples:
|
|||||||
|
|
||||||
### Global session list
|
### Global session list
|
||||||
|
|
||||||
Use `useGlobalSessionsStore` when the UI needs a **shared global session view**.
|
Use `useGlobalSessionsStore` when the UI needs a **shared global session cache**.
|
||||||
|
|
||||||
|
Current consumers:
|
||||||
|
|
||||||
|
- `useSessionAutoCleanup.ts`
|
||||||
|
|
||||||
|
### Live cross-directory session/status view
|
||||||
|
|
||||||
|
Use the sync hooks backed by aggregated child stores when the UI needs **live truth** for sessions or statuses across all initialized directories.
|
||||||
|
|
||||||
Current consumers:
|
Current consumers:
|
||||||
|
|
||||||
- `SessionSidebar.tsx`
|
- `SessionSidebar.tsx`
|
||||||
- `useSessionAutoCleanup.ts`
|
- `SessionNodeItem.tsx`
|
||||||
|
- `Header.tsx`
|
||||||
|
- agent/session activity surfaces using `useGlobalSessionStatus()` / `useAllSessionStatuses()`
|
||||||
|
|
||||||
### Mutation responsibility
|
### Mutation responsibility
|
||||||
|
|
||||||
@@ -83,7 +94,9 @@ Current consumers:
|
|||||||
- delete
|
- delete
|
||||||
- retention cleanup batch archive/delete
|
- retention cleanup batch archive/delete
|
||||||
|
|
||||||
This keeps sidebar/retention UI responsive without requiring a refetch after every change.
|
This keeps cold/global lists responsive without requiring a refetch after every change.
|
||||||
|
|
||||||
|
Live activity/status indicators must not depend on this cache. They must derive from aggregated child-store state.
|
||||||
|
|
||||||
## Session action rules
|
## Session action rules
|
||||||
|
|
||||||
|
|||||||
@@ -236,7 +236,7 @@ describe('createEventPipeline', () => {
|
|||||||
expect(received[0].payload.type).toBe('server.connected');
|
expect(received[0].payload.type).toBe('server.connected');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('delivers message.part.delta events after a coalesced message.part.updated (no stale-delta skip)', async () => {
|
it('skips stale message.part.delta events after a newer message.part.updated for the same field', async () => {
|
||||||
installDomStubs();
|
installDomStubs();
|
||||||
|
|
||||||
let releaseStream;
|
let releaseStream;
|
||||||
@@ -246,8 +246,8 @@ describe('createEventPipeline', () => {
|
|||||||
|
|
||||||
const received = [];
|
const received = [];
|
||||||
|
|
||||||
// Simulate: part.updated arrives first, then delta, then part.updated again (coalesces with first).
|
// Simulate: part.updated arrives, then delta, then a newer part.updated for the
|
||||||
// After coalescing, the delta should still be delivered — NOT skipped.
|
// same part. The older queued delta becomes stale and must be skipped.
|
||||||
const directory = '/test/dir';
|
const directory = '/test/dir';
|
||||||
const sdk = createSdkWithEvents([
|
const sdk = createSdkWithEvents([
|
||||||
// T0: message.part.updated for part-A
|
// T0: message.part.updated for part-A
|
||||||
@@ -260,7 +260,7 @@ describe('createEventPipeline', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// T1: message.part.delta for part-A (should flow through even after coalesce)
|
// T1: message.part.delta for part-A (should be dropped as stale)
|
||||||
{
|
{
|
||||||
payload: {
|
payload: {
|
||||||
type: 'message.part.delta',
|
type: 'message.part.delta',
|
||||||
@@ -290,7 +290,7 @@ describe('createEventPipeline', () => {
|
|||||||
sdk,
|
sdk,
|
||||||
onEvent: (dir, payload) => {
|
onEvent: (dir, payload) => {
|
||||||
received.push({ directory: dir, payload });
|
received.push({ directory: dir, payload });
|
||||||
if (received.length === 2) {
|
if (received.length === 1) {
|
||||||
cleanup();
|
cleanup();
|
||||||
releaseStream();
|
releaseStream();
|
||||||
resolve();
|
resolve();
|
||||||
@@ -301,20 +301,39 @@ describe('createEventPipeline', () => {
|
|||||||
|
|
||||||
await delivered;
|
await delivered;
|
||||||
|
|
||||||
// Coalescing means T0 and T2 merge into one event at T0's queue position.
|
expect(received.length).toBe(1);
|
||||||
// The delta is a different event type with no coalesce key, so it gets
|
|
||||||
// its own queue slot. After coalesce:
|
|
||||||
// - queue[0] = coalesced part.updated (from T2, replacing T0)
|
|
||||||
// - queue[1] = part.delta (from T1)
|
|
||||||
// Total: 2 events delivered
|
|
||||||
expect(received.length).toBe(2);
|
|
||||||
|
|
||||||
// The first event should be the coalesced message.part.updated
|
|
||||||
expect(received[0].payload.type).toBe('message.part.updated');
|
expect(received[0].payload.type).toBe('message.part.updated');
|
||||||
|
});
|
||||||
|
|
||||||
// The delta MUST be delivered — it should NOT be skipped
|
it('keeps delta events for other fields on the same part', async () => {
|
||||||
expect(received[1].payload.type).toBe('message.part.delta');
|
const received = await runPipelineWithEvents([
|
||||||
expect(received[1].payload.properties.delta).toBe(' world');
|
{
|
||||||
|
directory: 'dir-a',
|
||||||
|
payload: {
|
||||||
|
type: 'message.part.delta',
|
||||||
|
properties: {
|
||||||
|
messageID: 'msg-1',
|
||||||
|
partID: 'part-1',
|
||||||
|
field: 'reasoning',
|
||||||
|
delta: 'before',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
directory: 'dir-a',
|
||||||
|
payload: {
|
||||||
|
type: 'message.part.updated',
|
||||||
|
properties: {
|
||||||
|
part: { id: 'part-1', type: 'text', messageID: 'msg-1' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(received).toHaveLength(2);
|
||||||
|
expect(received[0].payload.type).toBe('message.part.delta');
|
||||||
|
expect(received[0].payload.properties.field).toBe('reasoning');
|
||||||
|
expect(received[1].payload.type).toBe('message.part.updated');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('coalesces message.part.updated events for the same part', async () => {
|
it('coalesces message.part.updated events for the same part', async () => {
|
||||||
@@ -367,6 +386,62 @@ describe('createEventPipeline', () => {
|
|||||||
expect(received.length).toBe(1);
|
expect(received.length).toBe(1);
|
||||||
expect(received[0].payload.type).toBe('message.part.updated');
|
expect(received[0].payload.type).toBe('message.part.updated');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('routes events before queueing so coalescing happens on the resolved directory', async () => {
|
||||||
|
installDomStubs();
|
||||||
|
|
||||||
|
let releaseStream;
|
||||||
|
const hold = new Promise((resolve) => {
|
||||||
|
releaseStream = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
const received = [];
|
||||||
|
const sdk = createSdkWithEvents([
|
||||||
|
{
|
||||||
|
directory: 'global',
|
||||||
|
payload: {
|
||||||
|
type: 'message.part.updated',
|
||||||
|
properties: {
|
||||||
|
part: { id: 'part-A', type: 'text', messageID: 'msg-1' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
directory: '/real-dir',
|
||||||
|
payload: {
|
||||||
|
type: 'message.part.updated',
|
||||||
|
properties: {
|
||||||
|
part: { id: 'part-A', type: 'text', messageID: 'msg-1', text: 'next' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
], hold);
|
||||||
|
|
||||||
|
const delivered = new Promise((resolve) => {
|
||||||
|
const { cleanup } = createEventPipeline({
|
||||||
|
sdk,
|
||||||
|
routeDirectory: (directory, payload) => {
|
||||||
|
if (payload.type === 'message.part.updated') {
|
||||||
|
return '/resolved-dir';
|
||||||
|
}
|
||||||
|
return directory;
|
||||||
|
},
|
||||||
|
onEvent: (dir, payload) => {
|
||||||
|
received.push({ directory: dir, payload });
|
||||||
|
cleanup();
|
||||||
|
releaseStream();
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await delivered;
|
||||||
|
|
||||||
|
expect(received).toHaveLength(1);
|
||||||
|
expect(received[0].directory).toBe('/resolved-dir');
|
||||||
|
expect(received[0].payload.type).toBe('message.part.updated');
|
||||||
|
expect(received[0].payload.properties.part.text).toBe('next');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -151,4 +151,50 @@ describe('applyDirectoryEvent', () => {
|
|||||||
|
|
||||||
expect(state.part[messageID]?.[0]?.text).toBe('haha')
|
expect(state.part[messageID]?.[0]?.text).toBe('haha')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('does not let a stale running tool update overwrite a completed tool part', () => {
|
||||||
|
const state = structuredClone(INITIAL_STATE)
|
||||||
|
const messageID = 'msg-5'
|
||||||
|
const partID = 'part-5'
|
||||||
|
|
||||||
|
applyDirectoryEvent(state, {
|
||||||
|
type: 'message.part.updated',
|
||||||
|
properties: {
|
||||||
|
part: {
|
||||||
|
id: partID,
|
||||||
|
type: 'tool',
|
||||||
|
messageID,
|
||||||
|
tool: 'apply_patch',
|
||||||
|
state: {
|
||||||
|
status: 'completed',
|
||||||
|
time: {
|
||||||
|
start: 10,
|
||||||
|
end: 20,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
applyDirectoryEvent(state, {
|
||||||
|
type: 'message.part.updated',
|
||||||
|
properties: {
|
||||||
|
part: {
|
||||||
|
id: partID,
|
||||||
|
type: 'tool',
|
||||||
|
messageID,
|
||||||
|
tool: 'apply_patch',
|
||||||
|
state: {
|
||||||
|
status: 'running',
|
||||||
|
time: {
|
||||||
|
start: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(state.part[messageID]?.[0]?.state?.status).toBe('completed')
|
||||||
|
expect(state.part[messageID]?.[0]?.state?.time?.end).toBe(20)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test'
|
||||||
|
|
||||||
|
import {
|
||||||
|
aggregateLiveSessions,
|
||||||
|
aggregateLiveSessionStatuses,
|
||||||
|
findLiveSession,
|
||||||
|
findLiveSessionStatus,
|
||||||
|
} from '../live-aggregate.ts'
|
||||||
|
import { deriveLiveActiveNowSessions } from '../../components/session/sidebar/activitySections.ts'
|
||||||
|
|
||||||
|
const session = (id, directory, updated, extra = {}) => ({
|
||||||
|
id,
|
||||||
|
title: `${id}-title`,
|
||||||
|
time: { created: updated - 1, updated, archived: undefined },
|
||||||
|
directory,
|
||||||
|
...extra,
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('live aggregate', () => {
|
||||||
|
it('prefers the freshest live session snapshot across child stores', () => {
|
||||||
|
const states = [
|
||||||
|
{
|
||||||
|
session: [session('ses-1', '/a', 10, { title: 'old' })],
|
||||||
|
session_status: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
session: [session('ses-1', '/a', 25, { title: 'new' }), session('ses-2', '/b', 20)],
|
||||||
|
session_status: {},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const sessions = aggregateLiveSessions(states)
|
||||||
|
expect(sessions.map((item) => `${item.id}:${item.title}`)).toEqual(['ses-1:new', 'ses-2:ses-2-title'])
|
||||||
|
expect(findLiveSession(states, 'ses-1')?.title).toBe('new')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prefers busy/retry statuses over stale idle snapshots', () => {
|
||||||
|
const states = [
|
||||||
|
{
|
||||||
|
session: [],
|
||||||
|
session_status: {
|
||||||
|
'ses-1': { type: 'idle' },
|
||||||
|
'ses-2': { type: 'idle' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
session: [],
|
||||||
|
session_status: {
|
||||||
|
'ses-1': { type: 'busy' },
|
||||||
|
'ses-2': { type: 'retry', message: 'retrying' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const statuses = aggregateLiveSessionStatuses(states)
|
||||||
|
expect(statuses['ses-1']?.type).toBe('busy')
|
||||||
|
expect(statuses['ses-2']?.type).toBe('retry')
|
||||||
|
expect(findLiveSessionStatus(states, 'ses-2')?.type).toBe('retry')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('derives active-now sessions from live statuses instead of persisted history', () => {
|
||||||
|
const sessions = [
|
||||||
|
session('ses-1', '/a', 20),
|
||||||
|
session('ses-2', '/b', 30),
|
||||||
|
session('ses-3', '/c', 10, { time: { created: 9, updated: 10, archived: 50 } }),
|
||||||
|
session('ses-4', '/d', 40, { parentID: 'ses-parent' }),
|
||||||
|
]
|
||||||
|
|
||||||
|
const activeNow = deriveLiveActiveNowSessions(sessions, {
|
||||||
|
'ses-1': { type: 'busy' },
|
||||||
|
'ses-2': { type: 'retry', message: 'retrying' },
|
||||||
|
'ses-3': { type: 'busy' },
|
||||||
|
'ses-4': { type: 'busy' },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(activeNow.map((item) => item.id)).toEqual(['ses-2', 'ses-1'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -39,12 +39,19 @@ export class ChildStoreManager {
|
|||||||
private readonly lifecycle = new Map<string, DirState>()
|
private readonly lifecycle = new Map<string, DirState>()
|
||||||
private readonly pins = new Map<string, number>()
|
private readonly pins = new Map<string, number>()
|
||||||
private readonly disposers = new Map<string, () => void>()
|
private readonly disposers = new Map<string, () => void>()
|
||||||
|
private readonly registrySubscribers = new Set<() => void>()
|
||||||
|
|
||||||
private onBootstrap?: (directory: string) => void
|
private onBootstrap?: (directory: string) => void
|
||||||
private onDispose?: (directory: string) => void
|
private onDispose?: (directory: string) => void
|
||||||
private isBooting?: (directory: string) => boolean
|
private isBooting?: (directory: string) => boolean
|
||||||
private isLoadingSessions?: (directory: string) => boolean
|
private isLoadingSessions?: (directory: string) => boolean
|
||||||
|
|
||||||
|
private notifyRegistrySubscribers() {
|
||||||
|
for (const subscriber of this.registrySubscribers) {
|
||||||
|
subscriber()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
configure(callbacks: {
|
configure(callbacks: {
|
||||||
onBootstrap?: (directory: string) => void
|
onBootstrap?: (directory: string) => void
|
||||||
onDispose?: (directory: string) => void
|
onDispose?: (directory: string) => void
|
||||||
@@ -91,6 +98,7 @@ export class ChildStoreManager {
|
|||||||
if (!store) {
|
if (!store) {
|
||||||
store = createDirectoryStore(directory)
|
store = createDirectoryStore(directory)
|
||||||
this.children.set(directory, store)
|
this.children.set(directory, store)
|
||||||
|
this.notifyRegistrySubscribers()
|
||||||
}
|
}
|
||||||
|
|
||||||
this.mark(directory)
|
this.mark(directory)
|
||||||
@@ -122,6 +130,7 @@ export class ChildStoreManager {
|
|||||||
|
|
||||||
this.lifecycle.delete(directory)
|
this.lifecycle.delete(directory)
|
||||||
this.children.delete(directory)
|
this.children.delete(directory)
|
||||||
|
this.notifyRegistrySubscribers()
|
||||||
const dispose = this.disposers.get(directory)
|
const dispose = this.disposers.get(directory)
|
||||||
if (dispose) {
|
if (dispose) {
|
||||||
dispose()
|
dispose()
|
||||||
@@ -165,8 +174,53 @@ export class ChildStoreManager {
|
|||||||
for (const directory of [...this.children.keys()]) {
|
for (const directory of [...this.children.keys()]) {
|
||||||
this.children.delete(directory)
|
this.children.delete(directory)
|
||||||
}
|
}
|
||||||
|
this.notifyRegistrySubscribers()
|
||||||
this.lifecycle.clear()
|
this.lifecycle.clear()
|
||||||
this.pins.clear()
|
this.pins.clear()
|
||||||
this.disposers.clear()
|
this.disposers.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
subscribeRegistry(listener: () => void): () => void {
|
||||||
|
this.registrySubscribers.add(listener)
|
||||||
|
return () => {
|
||||||
|
this.registrySubscribers.delete(listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribeAll(listener: () => void): () => void {
|
||||||
|
const storeUnsubscribers = new Map<string, () => void>()
|
||||||
|
|
||||||
|
const syncStoreSubscriptions = () => {
|
||||||
|
const activeDirectories = new Set(this.children.keys())
|
||||||
|
|
||||||
|
for (const [directory, unsubscribe] of storeUnsubscribers.entries()) {
|
||||||
|
if (activeDirectories.has(directory)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
unsubscribe()
|
||||||
|
storeUnsubscribers.delete(directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [directory, store] of this.children.entries()) {
|
||||||
|
if (storeUnsubscribers.has(directory)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
storeUnsubscribers.set(directory, store.subscribe(listener))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
syncStoreSubscriptions()
|
||||||
|
const unsubscribeRegistry = this.subscribeRegistry(() => {
|
||||||
|
syncStoreSubscriptions()
|
||||||
|
listener()
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribeRegistry()
|
||||||
|
for (const unsubscribe of storeUnsubscribers.values()) {
|
||||||
|
unsubscribe()
|
||||||
|
}
|
||||||
|
storeUnsubscribers.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ const HEARTBEAT_TIMEOUT_MS = 15_000
|
|||||||
export type EventPipelineInput = {
|
export type EventPipelineInput = {
|
||||||
sdk: OpencodeClient
|
sdk: OpencodeClient
|
||||||
onEvent: (directory: string, payload: Event) => void
|
onEvent: (directory: string, payload: Event) => void
|
||||||
|
routeDirectory?: (directory: string, payload: Event) => string
|
||||||
/** Called after SSE reconnects (visibility restore or heartbeat timeout). */
|
/** Called after SSE reconnects (visibility restore or heartbeat timeout). */
|
||||||
onReconnect?: () => void
|
onReconnect?: () => void
|
||||||
}
|
}
|
||||||
@@ -85,12 +86,13 @@ type DirectoryQueue = {
|
|||||||
queue: Event[]
|
queue: Event[]
|
||||||
buffer: Event[]
|
buffer: Event[]
|
||||||
coalesced: Map<string, number>
|
coalesced: Map<string, number>
|
||||||
|
staleDeltas: Set<string>
|
||||||
timer: ReturnType<typeof setTimeout> | undefined
|
timer: ReturnType<typeof setTimeout> | undefined
|
||||||
last: number
|
last: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createEventPipeline(input: EventPipelineInput) {
|
export function createEventPipeline(input: EventPipelineInput) {
|
||||||
const { sdk, onEvent, onReconnect } = input
|
const { sdk, onEvent, onReconnect, routeDirectory } = input
|
||||||
const abort = new AbortController()
|
const abort = new AbortController()
|
||||||
let hasConnected = false
|
let hasConnected = false
|
||||||
|
|
||||||
@@ -104,6 +106,7 @@ export function createEventPipeline(input: EventPipelineInput) {
|
|||||||
queue: [],
|
queue: [],
|
||||||
buffer: [],
|
buffer: [],
|
||||||
coalesced: new Map(),
|
coalesced: new Map(),
|
||||||
|
staleDeltas: new Set(),
|
||||||
timer: undefined,
|
timer: undefined,
|
||||||
last: 0,
|
last: 0,
|
||||||
}
|
}
|
||||||
@@ -136,6 +139,8 @@ export function createEventPipeline(input: EventPipelineInput) {
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deltaKey = (messageID: string, partID: string, field: string) => `${messageID}:${partID}:${field}`
|
||||||
|
|
||||||
// Flush one directory — swap queue, dispatch events.
|
// Flush one directory — swap queue, dispatch events.
|
||||||
// React 18 auto-batching still collapses the setState calls inside a single
|
// React 18 auto-batching still collapses the setState calls inside a single
|
||||||
// directory's flush into one render pass.
|
// directory's flush into one render pass.
|
||||||
@@ -149,14 +154,22 @@ export function createEventPipeline(input: EventPipelineInput) {
|
|||||||
if (d.queue.length === 0) return
|
if (d.queue.length === 0) return
|
||||||
|
|
||||||
const events = d.queue
|
const events = d.queue
|
||||||
|
const staleDeltas = d.staleDeltas.size > 0 ? new Set(d.staleDeltas) : undefined
|
||||||
d.queue = d.buffer
|
d.queue = d.buffer
|
||||||
d.buffer = events
|
d.buffer = events
|
||||||
d.queue.length = 0
|
d.queue.length = 0
|
||||||
d.coalesced.clear()
|
d.coalesced.clear()
|
||||||
|
d.staleDeltas.clear()
|
||||||
|
|
||||||
d.last = Date.now()
|
d.last = Date.now()
|
||||||
syncDebug.pipeline.flush(events.length)
|
syncDebug.pipeline.flush(events.length)
|
||||||
for (const payload of events) {
|
for (const payload of events) {
|
||||||
|
if (staleDeltas && payload.type === "message.part.delta") {
|
||||||
|
const props = payload.properties as { messageID: string; partID: string; field: string }
|
||||||
|
if (staleDeltas.has(deltaKey(props.messageID, props.partID, props.field))) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
onEvent(directory, payload)
|
onEvent(directory, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,7 +254,8 @@ export function createEventPipeline(input: EventPipelineInput) {
|
|||||||
}
|
}
|
||||||
const normalizedPayload = normalizeEventType(payload)
|
const normalizedPayload = normalizeEventType(payload)
|
||||||
const directory = resolveEventDirectory(event, normalizedPayload)
|
const directory = resolveEventDirectory(event, normalizedPayload)
|
||||||
const d = getOrCreateDir(directory)
|
const routedDirectory = routeDirectory?.(directory, normalizedPayload) || directory
|
||||||
|
const d = getOrCreateDir(routedDirectory)
|
||||||
const k = key(normalizedPayload)
|
const k = key(normalizedPayload)
|
||||||
if (k) {
|
if (k) {
|
||||||
const i = d.coalesced.get(k)
|
const i = d.coalesced.get(k)
|
||||||
@@ -261,14 +275,24 @@ export function createEventPipeline(input: EventPipelineInput) {
|
|||||||
} as unknown as Event
|
} as unknown as Event
|
||||||
} else {
|
} else {
|
||||||
d.queue[i] = normalizedPayload
|
d.queue[i] = normalizedPayload
|
||||||
|
if (normalizedPayload.type === "message.part.updated") {
|
||||||
|
const part = (normalizedPayload.properties as { part: { messageID: string; id: string } }).part
|
||||||
|
d.staleDeltas.add(deltaKey(part.messageID, part.id, "text"))
|
||||||
|
d.staleDeltas.add(deltaKey(part.messageID, part.id, "output"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
syncDebug.pipeline.coalesced(normalizedPayload.type, k)
|
syncDebug.pipeline.coalesced(normalizedPayload.type, k)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
d.coalesced.set(k, d.queue.length)
|
d.coalesced.set(k, d.queue.length)
|
||||||
}
|
}
|
||||||
|
if (normalizedPayload.type === "message.part.updated") {
|
||||||
|
const part = (normalizedPayload.properties as { part: { messageID: string; id: string } }).part
|
||||||
|
d.staleDeltas.add(deltaKey(part.messageID, part.id, "text"))
|
||||||
|
d.staleDeltas.add(deltaKey(part.messageID, part.id, "output"))
|
||||||
|
}
|
||||||
d.queue.push(normalizedPayload)
|
d.queue.push(normalizedPayload)
|
||||||
scheduleDir(directory)
|
scheduleDir(routedDirectory)
|
||||||
|
|
||||||
if (Date.now() - yielded < STREAM_YIELD_MS) continue
|
if (Date.now() - yielded < STREAM_YIELD_MS) continue
|
||||||
yielded = Date.now()
|
yielded = Date.now()
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { syncDebug } from "./debug"
|
|||||||
|
|
||||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||||
const DELTA_OVERLAP_FIELDS = ["text", "output"] as const
|
const DELTA_OVERLAP_FIELDS = ["text", "output"] as const
|
||||||
|
const FINAL_TOOL_STATUSES = new Set(["completed", "error", "aborted", "failed", "timeout", "cancelled"])
|
||||||
|
|
||||||
type DedupeMetadata = {
|
type DedupeMetadata = {
|
||||||
__dedupeNextDeltaFields?: string[]
|
__dedupeNextDeltaFields?: string[]
|
||||||
@@ -50,6 +51,45 @@ function getUpdatedDeltaFields(previous: Part, next: Part) {
|
|||||||
return dedupeFields
|
return dedupeFields
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPartEndTime(part: Part): number | undefined {
|
||||||
|
const stateEnd = (part as { state?: { time?: { end?: unknown } } }).state?.time?.end
|
||||||
|
if (typeof stateEnd === "number") {
|
||||||
|
return stateEnd
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeEnd = (part as { time?: { end?: unknown } }).time?.end
|
||||||
|
return typeof timeEnd === "number" ? timeEnd : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToolStatus(part: Part): string | undefined {
|
||||||
|
if (part.type !== "tool") {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = (part as { state?: { status?: unknown } }).state?.status
|
||||||
|
return typeof status === "string" ? status : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldPreserveExistingPart(previous: Part, next: Part): boolean {
|
||||||
|
if (previous.type !== "tool" || next.type !== "tool") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousStatus = getToolStatus(previous)
|
||||||
|
const nextStatus = getToolStatus(next)
|
||||||
|
if (previousStatus && FINAL_TOOL_STATUSES.has(previousStatus) && (!nextStatus || !FINAL_TOOL_STATUSES.has(nextStatus))) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousEnd = getPartEndTime(previous)
|
||||||
|
const nextEnd = getPartEndTime(next)
|
||||||
|
if (typeof previousEnd === "number" && typeof nextEnd !== "number") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Global events
|
// Global events
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -239,6 +279,9 @@ export function applyDirectoryEvent(
|
|||||||
const result = Binary.search(next, part.id, (p) => p.id)
|
const result = Binary.search(next, part.id, (p) => p.id)
|
||||||
if (result.found) {
|
if (result.found) {
|
||||||
const previous = next[result.index]
|
const previous = next[result.index]
|
||||||
|
if (shouldPreserveExistingPart(previous, part)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
const dedupeFields = getUpdatedDeltaFields(previous, part)
|
const dedupeFields = getUpdatedDeltaFields(previous, part)
|
||||||
next[result.index] = dedupeFields.length > 0
|
next[result.index] = dedupeFields.length > 0
|
||||||
? { ...part, __dedupeNextDeltaFields: dedupeFields } as unknown as Part
|
? { ...part, __dedupeNextDeltaFields: dedupeFields } as unknown as Part
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import type { SessionStatus } from '@opencode-ai/sdk/v2/client'
|
||||||
|
import type { Session } from '@opencode-ai/sdk/v2'
|
||||||
|
import type { State } from './types'
|
||||||
|
|
||||||
|
type LiveStateSlice = Pick<State, 'session' | 'session_status'>
|
||||||
|
|
||||||
|
const getSessionUpdatedAt = (session: Session): number => {
|
||||||
|
const updatedAt = session.time?.updated
|
||||||
|
if (typeof updatedAt === 'number' && Number.isFinite(updatedAt)) {
|
||||||
|
return updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdAt = session.time?.created
|
||||||
|
return typeof createdAt === 'number' && Number.isFinite(createdAt) ? createdAt : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSessionSignature = (session: Session): string => {
|
||||||
|
const directory = (session as Session & { directory?: string | null }).directory ?? ''
|
||||||
|
const parentID = (session as Session & { parentID?: string | null }).parentID ?? ''
|
||||||
|
return [
|
||||||
|
session.id,
|
||||||
|
session.title ?? '',
|
||||||
|
session.time?.created ?? 0,
|
||||||
|
session.time?.updated ?? 0,
|
||||||
|
session.time?.archived ?? 0,
|
||||||
|
directory,
|
||||||
|
parentID,
|
||||||
|
session.share?.url ?? '',
|
||||||
|
].join('|')
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusPriority = (status: SessionStatus | undefined): number => {
|
||||||
|
switch (status?.type) {
|
||||||
|
case 'retry':
|
||||||
|
return 4
|
||||||
|
case 'busy':
|
||||||
|
return 3
|
||||||
|
case 'idle':
|
||||||
|
return 1
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusMessage = (status: SessionStatus | undefined): string | null => {
|
||||||
|
const message = (status as { message?: unknown } | undefined)?.message
|
||||||
|
return typeof message === 'string' ? message : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const areSessionListsEquivalent = (left: Session[], right: Session[]): boolean => {
|
||||||
|
if (left === right) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (left.length !== right.length) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < left.length; index += 1) {
|
||||||
|
if (getSessionSignature(left[index]) !== getSessionSignature(right[index])) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export const areStatusMapsEquivalent = (
|
||||||
|
left: Record<string, SessionStatus>,
|
||||||
|
right: Record<string, SessionStatus>,
|
||||||
|
): boolean => {
|
||||||
|
if (left === right) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftKeys = Object.keys(left)
|
||||||
|
const rightKeys = Object.keys(right)
|
||||||
|
if (leftKeys.length !== rightKeys.length) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of leftKeys) {
|
||||||
|
if (!(key in right)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const leftStatus = left[key]
|
||||||
|
const rightStatus = right[key]
|
||||||
|
if (leftStatus?.type !== rightStatus?.type) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (getStatusMessage(leftStatus) !== getStatusMessage(rightStatus)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aggregateLiveSessions(states: Iterable<LiveStateSlice>): Session[] {
|
||||||
|
const sessionsById = new Map<string, Session>()
|
||||||
|
|
||||||
|
for (const state of states) {
|
||||||
|
for (const session of state.session) {
|
||||||
|
if (!session?.id) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const current = sessionsById.get(session.id)
|
||||||
|
if (!current || getSessionUpdatedAt(session) >= getSessionUpdatedAt(current)) {
|
||||||
|
sessionsById.set(session.id, session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(sessionsById.values()).sort((left, right) => {
|
||||||
|
return getSessionUpdatedAt(right) - getSessionUpdatedAt(left)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aggregateLiveSessionStatuses(states: Iterable<LiveStateSlice>): Record<string, SessionStatus> {
|
||||||
|
const statuses: Record<string, SessionStatus> = {}
|
||||||
|
|
||||||
|
for (const state of states) {
|
||||||
|
for (const [sessionId, status] of Object.entries(state.session_status ?? {})) {
|
||||||
|
const current = statuses[sessionId]
|
||||||
|
if (!current || getStatusPriority(status) >= getStatusPriority(current)) {
|
||||||
|
statuses[sessionId] = status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return statuses
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findLiveSession(states: Iterable<LiveStateSlice>, sessionID?: string | null): Session | undefined {
|
||||||
|
if (!sessionID) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
let match: Session | undefined
|
||||||
|
for (const state of states) {
|
||||||
|
const session = state.session.find((candidate) => candidate.id === sessionID)
|
||||||
|
if (!session) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!match || getSessionUpdatedAt(session) >= getSessionUpdatedAt(match)) {
|
||||||
|
match = session
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findLiveSessionStatus(
|
||||||
|
states: Iterable<LiveStateSlice>,
|
||||||
|
sessionID?: string | null,
|
||||||
|
): SessionStatus | undefined {
|
||||||
|
if (!sessionID) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
let match: SessionStatus | undefined
|
||||||
|
for (const state of states) {
|
||||||
|
const status = state.session_status?.[sessionID]
|
||||||
|
if (!status) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!match || getStatusPriority(status) >= getStatusPriority(match)) {
|
||||||
|
match = status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return match
|
||||||
|
}
|
||||||
@@ -7,22 +7,21 @@ import type { OpencodeClient, Session, Message, Part } from "@opencode-ai/sdk/v2
|
|||||||
import { Binary } from "./binary"
|
import { Binary } from "./binary"
|
||||||
import { useSessionUIStore } from "./session-ui-store"
|
import { useSessionUIStore } from "./session-ui-store"
|
||||||
import { useInputStore } from "./input-store"
|
import { useInputStore } from "./input-store"
|
||||||
import type { DirectoryStore } from "./child-store"
|
import type { ChildStoreManager } from "./child-store"
|
||||||
import type { StoreApi } from "zustand"
|
|
||||||
import { opencodeClient } from "@/lib/opencode/client"
|
import { opencodeClient } from "@/lib/opencode/client"
|
||||||
import { useGlobalSessionsStore } from "@/stores/useGlobalSessionsStore"
|
import { useGlobalSessionsStore } from "@/stores/useGlobalSessionsStore"
|
||||||
import { registerSessionDirectory } from "./sync-refs"
|
import { registerSessionDirectory } from "./sync-refs"
|
||||||
|
|
||||||
// Reference set by SyncProvider — allows actions to access SDK and stores
|
// Reference set by SyncProvider — allows actions to access SDK and stores
|
||||||
let _sdk: OpencodeClient | null = null
|
let _sdk: OpencodeClient | null = null
|
||||||
let _childStores: { ensureChild: (dir: string) => StoreApi<DirectoryStore> } | null = null
|
let _childStores: ChildStoreManager | null = null
|
||||||
let _getDirectory: () => string = () => ""
|
let _getDirectory: () => string = () => ""
|
||||||
let _optimisticAdd: ((input: { sessionID: string; message: Message; parts: Part[] }) => void) | null = null
|
let _optimisticAdd: ((input: { sessionID: string; message: Message; parts: Part[] }) => void) | null = null
|
||||||
let _optimisticRemove: ((input: { sessionID: string; messageID: string }) => void) | null = null
|
let _optimisticRemove: ((input: { sessionID: string; messageID: string }) => void) | null = null
|
||||||
|
|
||||||
export function setActionRefs(
|
export function setActionRefs(
|
||||||
sdk: OpencodeClient,
|
sdk: OpencodeClient,
|
||||||
childStores: { ensureChild: (dir: string) => StoreApi<DirectoryStore> },
|
childStores: ChildStoreManager,
|
||||||
getDirectory: () => string,
|
getDirectory: () => string,
|
||||||
) {
|
) {
|
||||||
_sdk = sdk
|
_sdk = sdk
|
||||||
@@ -75,6 +74,59 @@ function getSessionReplyClient(sessionId?: string): OpencodeClient {
|
|||||||
return sdk()
|
return sdk()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveDirectoryForBlockingRequest(
|
||||||
|
type: "permission" | "question",
|
||||||
|
sessionId: string,
|
||||||
|
requestId: string,
|
||||||
|
): string | null {
|
||||||
|
const stores = _childStores
|
||||||
|
if (!stores || !requestId) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [directory, store] of stores.children) {
|
||||||
|
const state = store.getState()
|
||||||
|
const requestMap = type === "permission" ? state.permission : state.question
|
||||||
|
for (const requests of Object.values(requestMap) as Array<Array<{ id: string }> | undefined>) {
|
||||||
|
if (requests?.some((request) => request.id === requestId)) {
|
||||||
|
return directory
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionDirectory = useSessionUIStore.getState().getDirectoryForSession(sessionId)
|
||||||
|
if (sessionDirectory) {
|
||||||
|
return sessionDirectory
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [directory, store] of stores.children) {
|
||||||
|
const state = store.getState()
|
||||||
|
if (
|
||||||
|
state.session.some((session) => session.id === sessionId)
|
||||||
|
|| Object.prototype.hasOwnProperty.call(state.message, sessionId)
|
||||||
|
|| Object.prototype.hasOwnProperty.call(state.session_status ?? {}, sessionId)
|
||||||
|
|| Object.prototype.hasOwnProperty.call(state.permission ?? {}, sessionId)
|
||||||
|
|| Object.prototype.hasOwnProperty.call(state.question ?? {}, sessionId)
|
||||||
|
) {
|
||||||
|
return directory
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRequestReplyClient(
|
||||||
|
type: "permission" | "question",
|
||||||
|
sessionId: string,
|
||||||
|
requestId: string,
|
||||||
|
): OpencodeClient {
|
||||||
|
const requestDirectory = resolveDirectoryForBlockingRequest(type, sessionId, requestId)
|
||||||
|
if (requestDirectory) {
|
||||||
|
return opencodeClient.getScopedSdkClient(requestDirectory)
|
||||||
|
}
|
||||||
|
return getSessionReplyClient(sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Session CRUD
|
// Session CRUD
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -356,7 +408,7 @@ export async function respondToPermission(
|
|||||||
requestId: string,
|
requestId: string,
|
||||||
response: "once" | "always" | "reject",
|
response: "once" | "always" | "reject",
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const result = await getSessionReplyClient(sessionId).permission.reply({
|
const result = await getRequestReplyClient("permission", sessionId, requestId).permission.reply({
|
||||||
requestID: requestId,
|
requestID: requestId,
|
||||||
reply: response,
|
reply: response,
|
||||||
})
|
})
|
||||||
@@ -369,7 +421,7 @@ export async function dismissPermission(
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
requestId: string,
|
requestId: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const result = await getSessionReplyClient(sessionId).permission.reply({
|
const result = await getRequestReplyClient("permission", sessionId, requestId).permission.reply({
|
||||||
requestID: requestId,
|
requestID: requestId,
|
||||||
reply: "reject",
|
reply: "reject",
|
||||||
})
|
})
|
||||||
@@ -387,7 +439,7 @@ export async function respondToQuestion(
|
|||||||
requestId: string,
|
requestId: string,
|
||||||
answers: string[] | string[][],
|
answers: string[] | string[][],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const result = await getSessionReplyClient(sessionId).question.reply({
|
const result = await getRequestReplyClient("question", sessionId, requestId).question.reply({
|
||||||
requestID: requestId,
|
requestID: requestId,
|
||||||
answers: answers as Array<Array<string>>,
|
answers: answers as Array<Array<string>>,
|
||||||
})
|
})
|
||||||
@@ -400,7 +452,7 @@ export async function rejectQuestion(
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
requestId: string,
|
requestId: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const result = await getSessionReplyClient(sessionId).question.reject({
|
const result = await getRequestReplyClient("question", sessionId, requestId).question.reject({
|
||||||
requestID: requestId,
|
requestID: requestId,
|
||||||
})
|
})
|
||||||
if (!result.data) {
|
if (!result.data) {
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ import { createEventPipeline } from "./event-pipeline"
|
|||||||
import { reduceGlobalEvent, applyGlobalProject, applyDirectoryEvent } from "./event-reducer"
|
import { reduceGlobalEvent, applyGlobalProject, applyDirectoryEvent } from "./event-reducer"
|
||||||
import { useGlobalSyncStore, type GlobalSyncStore } from "./global-sync-store"
|
import { useGlobalSyncStore, type GlobalSyncStore } from "./global-sync-store"
|
||||||
import { ChildStoreManager, type DirectoryStore } from "./child-store"
|
import { ChildStoreManager, type DirectoryStore } from "./child-store"
|
||||||
|
import {
|
||||||
|
aggregateLiveSessions,
|
||||||
|
aggregateLiveSessionStatuses,
|
||||||
|
areSessionListsEquivalent,
|
||||||
|
areStatusMapsEquivalent,
|
||||||
|
findLiveSession,
|
||||||
|
findLiveSessionStatus,
|
||||||
|
} from "./live-aggregate"
|
||||||
import { bootstrapGlobal, bootstrapDirectory } from "./bootstrap"
|
import { bootstrapGlobal, bootstrapDirectory } from "./bootstrap"
|
||||||
import { retry } from "./retry"
|
import { retry } from "./retry"
|
||||||
import { updateStreamingState } from "./streaming"
|
import { updateStreamingState } from "./streaming"
|
||||||
@@ -24,7 +32,6 @@ import type { State } from "./types"
|
|||||||
import type { SessionStatus } from "@opencode-ai/sdk/v2/client"
|
import type { SessionStatus } from "@opencode-ai/sdk/v2/client"
|
||||||
import type { PermissionRequest } from "@/types/permission"
|
import type { PermissionRequest } from "@/types/permission"
|
||||||
import type { QuestionRequest } from "@/types/question"
|
import type { QuestionRequest } from "@/types/question"
|
||||||
import { create } from "zustand"
|
|
||||||
import * as sessionActions from "./session-actions"
|
import * as sessionActions from "./session-actions"
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -52,44 +59,59 @@ function useSyncSystem() {
|
|||||||
return ctx
|
return ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getLiveStates(childStores: ChildStoreManager): State[] {
|
||||||
|
return Array.from(childStores.children.values(), (store) => store.getState())
|
||||||
|
}
|
||||||
|
|
||||||
|
function useLiveSyncSelector<T>(selector: (states: State[]) => T, isEqual: (left: T, right: T) => boolean = Object.is): T {
|
||||||
|
const { childStores } = useSyncSystem()
|
||||||
|
const cacheRef = useRef<T | undefined>(undefined)
|
||||||
|
const initializedRef = useRef(false)
|
||||||
|
|
||||||
|
const getSnapshot = useCallback(() => {
|
||||||
|
const next = selector(getLiveStates(childStores))
|
||||||
|
if (initializedRef.current && isEqual(cacheRef.current as T, next)) {
|
||||||
|
return cacheRef.current as T
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheRef.current = next
|
||||||
|
initializedRef.current = true
|
||||||
|
return next
|
||||||
|
}, [childStores, isEqual, selector])
|
||||||
|
|
||||||
|
return React.useSyncExternalStore(
|
||||||
|
useCallback((notify) => childStores.subscribeAll(notify), [childStores]),
|
||||||
|
getSnapshot,
|
||||||
|
getSnapshot,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Event handler — applies one SSE event at a time to the live store.
|
// Event handler — applies one SSE event at a time to the live store.
|
||||||
// Each event reads live state, creates a shallow draft, applies, writes back.
|
// Each event reads live state, creates a shallow draft, applies, writes back.
|
||||||
// React 18 batches synchronous setState calls automatically.
|
// React 18 batches synchronous setState calls automatically.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Global session status store — cross-directory status tracking.
|
|
||||||
//
|
|
||||||
// OpenCode isolates sessions behind project navrails, so per-directory
|
|
||||||
// session_status is sufficient. OpenChamber shows all sessions in one sidebar,
|
|
||||||
// so we need a global view. Updated from handleEvent on every session.status.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
interface GlobalSessionStatusStore {
|
|
||||||
statuses: Record<string, SessionStatus>
|
|
||||||
}
|
|
||||||
|
|
||||||
const useGlobalSessionStatusStore = create<GlobalSessionStatusStore>(() => ({
|
|
||||||
statuses: {},
|
|
||||||
}))
|
|
||||||
|
|
||||||
function setGlobalSessionStatus(sessionId: string, status: SessionStatus) {
|
|
||||||
const current = useGlobalSessionStatusStore.getState().statuses
|
|
||||||
if (current[sessionId] === status) return
|
|
||||||
useGlobalSessionStatusStore.setState({
|
|
||||||
statuses: { ...current, [sessionId]: status },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Read status for a session across all directories */
|
/** Read status for a session across all directories */
|
||||||
export function useGlobalSessionStatus(sessionId: string): SessionStatus | undefined {
|
export function useGlobalSessionStatus(sessionId: string): SessionStatus | undefined {
|
||||||
return useGlobalSessionStatusStore((s) => s.statuses[sessionId])
|
return useLiveSyncSelector(
|
||||||
|
useCallback((states) => findLiveSessionStatus(states, sessionId), [sessionId]),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Read all session statuses (for sidebar) */
|
/** Read all session statuses (for sidebar) */
|
||||||
export function useAllSessionStatuses(): Record<string, SessionStatus> {
|
export function useAllSessionStatuses(): Record<string, SessionStatus> {
|
||||||
return useGlobalSessionStatusStore((s) => s.statuses)
|
return useLiveSyncSelector(
|
||||||
|
useCallback((states) => aggregateLiveSessionStatuses(states), []),
|
||||||
|
areStatusMapsEquivalent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAllLiveSessions(): Session[] {
|
||||||
|
return useLiveSyncSelector(
|
||||||
|
useCallback((states) => aggregateLiveSessions(states), []),
|
||||||
|
areSessionListsEquivalent,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Boot debounce — suppresses redundant refresh/re-bootstrap events during startup.
|
// Boot debounce — suppresses redundant refresh/re-bootstrap events during startup.
|
||||||
@@ -108,6 +130,34 @@ const requestSignature = (items: Array<{ id: string }> | undefined): string => {
|
|||||||
|
|
||||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||||
|
|
||||||
|
const partRepairSignature = (part: Part): string => JSON.stringify(part)
|
||||||
|
|
||||||
|
function haveEquivalentPartSnapshots(left: Part[] | undefined, right: Part[]): boolean {
|
||||||
|
if (!left) {
|
||||||
|
return right.length === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (left.length !== right.length) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < left.length; index += 1) {
|
||||||
|
const leftPart = left[index]
|
||||||
|
const rightPart = right[index]
|
||||||
|
if (!leftPart || !rightPart) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (leftPart.id !== rightPart.id) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (partRepairSignature(leftPart) !== partRepairSignature(rightPart)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Parts-gap recovery — when SSE events arrive but parts are missing,
|
// Parts-gap recovery — when SSE events arrive but parts are missing,
|
||||||
// trigger a targeted re-fetch for the affected sessions.
|
// trigger a targeted re-fetch for the affected sessions.
|
||||||
@@ -172,8 +222,8 @@ async function repairSessionParts(
|
|||||||
.sort((a: Part, b: Part) => cmp(a.id, b.id))
|
.sort((a: Part, b: Part) => cmp(a.id, b.id))
|
||||||
|
|
||||||
const existing = nextPartState[messageId]
|
const existing = nextPartState[messageId]
|
||||||
// Only patch if parts were missing or fewer than server has
|
// Repair when parts are missing, truncated, or stale-but-same-length.
|
||||||
if (!existing || existing.length < newParts.length) {
|
if (!haveEquivalentPartSnapshots(existing, newParts)) {
|
||||||
nextPartState[messageId] = newParts
|
nextPartState[messageId] = newParts
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -201,22 +251,6 @@ function isRecentBoot() {
|
|||||||
return bootingRoot || Date.now() - bootedAt < BOOT_DEBOUNCE_MS
|
return bootingRoot || Date.now() - bootedAt < BOOT_DEBOUNCE_MS
|
||||||
}
|
}
|
||||||
|
|
||||||
function setGlobalSessionStatuses(nextStatuses: Record<string, SessionStatus>) {
|
|
||||||
const current = useGlobalSessionStatusStore.getState().statuses
|
|
||||||
let changed = false
|
|
||||||
const merged = { ...current }
|
|
||||||
|
|
||||||
for (const [sessionId, status] of Object.entries(nextStatuses)) {
|
|
||||||
if (!status || merged[sessionId] === status) continue
|
|
||||||
merged[sessionId] = status
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (changed) {
|
|
||||||
useGlobalSessionStatusStore.setState({ statuses: merged })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getReconnectCandidateSessionIds(state: State) {
|
function getReconnectCandidateSessionIds(state: State) {
|
||||||
const ids = new Set<string>()
|
const ids = new Set<string>()
|
||||||
|
|
||||||
@@ -662,7 +696,6 @@ async function resyncDirectoryAfterReconnect(
|
|||||||
store.setState((state: DirectoryStore) => ({
|
store.setState((state: DirectoryStore) => ({
|
||||||
session_status: { ...state.session_status, ...relevantStatuses },
|
session_status: { ...state.session_status, ...relevantStatuses },
|
||||||
}))
|
}))
|
||||||
setGlobalSessionStatuses(relevantStatuses)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const scopedClient = opencodeClient.getScopedSdkClient(directory)
|
const scopedClient = opencodeClient.getScopedSdkClient(directory)
|
||||||
@@ -733,6 +766,13 @@ async function resyncDirectoryAfterReconnect(
|
|||||||
// If SSE changed a session while the request was in-flight, keep that data.
|
// If SSE changed a session while the request was in-flight, keep that data.
|
||||||
try {
|
try {
|
||||||
const before = store.getState()
|
const before = store.getState()
|
||||||
|
const knownSessionIds = new Set<string>([
|
||||||
|
...before.session.map((session) => session.id),
|
||||||
|
...Object.keys(before.message ?? {}),
|
||||||
|
...Object.keys(before.session_status ?? {}),
|
||||||
|
...Object.keys(before.question ?? {}),
|
||||||
|
...Object.keys(before.permission ?? {}),
|
||||||
|
])
|
||||||
const beforeSignatures = new Map(
|
const beforeSignatures = new Map(
|
||||||
candidateSessionIds.map((sessionId) => [sessionId, requestSignature(before.question[sessionId])]),
|
candidateSessionIds.map((sessionId) => [sessionId, requestSignature(before.question[sessionId])]),
|
||||||
)
|
)
|
||||||
@@ -740,6 +780,7 @@ async function resyncDirectoryAfterReconnect(
|
|||||||
const grouped: Record<string, QuestionRequest[]> = {}
|
const grouped: Record<string, QuestionRequest[]> = {}
|
||||||
for (const q of pendingQuestions) {
|
for (const q of pendingQuestions) {
|
||||||
if (!q?.id || !q.sessionID) continue
|
if (!q?.id || !q.sessionID) continue
|
||||||
|
if (!knownSessionIds.has(q.sessionID)) continue
|
||||||
const list = grouped[q.sessionID]
|
const list = grouped[q.sessionID]
|
||||||
if (list) list.push(q)
|
if (list) list.push(q)
|
||||||
else grouped[q.sessionID] = [q]
|
else grouped[q.sessionID] = [q]
|
||||||
@@ -956,17 +997,6 @@ function handleEvent(
|
|||||||
|
|
||||||
updateRoutingIndexFromEvent(routingIndex, resolvedDirectory, payload)
|
updateRoutingIndexFromEvent(routingIndex, resolvedDirectory, payload)
|
||||||
|
|
||||||
// Update global session status for cross-directory sidebar visibility
|
|
||||||
if (payload.type === "session.status") {
|
|
||||||
const props = payload.properties as { sessionID: string; status: SessionStatus }
|
|
||||||
setGlobalSessionStatus(props.sessionID, props.status)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (payload.type === "session.idle" || payload.type === "session.error") {
|
|
||||||
const props = payload.properties as { sessionID: string }
|
|
||||||
setGlobalSessionStatus(props.sessionID, { type: "idle" })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (payload.type === "permission.asked") {
|
if (payload.type === "permission.asked") {
|
||||||
const nd = normalizeDirectory(resolvedDirectory)
|
const nd = normalizeDirectory(resolvedDirectory)
|
||||||
if (!nd) {
|
if (!nd) {
|
||||||
@@ -1030,11 +1060,6 @@ export function SyncProvider(props: {
|
|||||||
if (patch.session || patch.message) {
|
if (patch.session || patch.message) {
|
||||||
ingestDirectoryStateIntoRoutingIndex(routingIndex, directory, store.getState())
|
ingestDirectoryStateIntoRoutingIndex(routingIndex, directory, store.getState())
|
||||||
}
|
}
|
||||||
if (patch.session_status) {
|
|
||||||
const current = useGlobalSessionStatusStore.getState().statuses
|
|
||||||
const merged = { ...current, ...patch.session_status }
|
|
||||||
useGlobalSessionStatusStore.setState({ statuses: merged })
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
global: {
|
global: {
|
||||||
config: globalState.config,
|
config: globalState.config,
|
||||||
@@ -1103,6 +1128,9 @@ export function SyncProvider(props: {
|
|||||||
|
|
||||||
const { cleanup } = createEventPipeline({
|
const { cleanup } = createEventPipeline({
|
||||||
sdk: props.sdk,
|
sdk: props.sdk,
|
||||||
|
routeDirectory: (directory, payload) => {
|
||||||
|
return resolveDirectoryFromRoutingIndex(routingIndex, directory, payload, childStores)
|
||||||
|
},
|
||||||
onEvent: (directory, payload) => {
|
onEvent: (directory, payload) => {
|
||||||
handleEvent(directory, payload, childStores, routingIndex)
|
handleEvent(directory, payload, childStores, routingIndex)
|
||||||
},
|
},
|
||||||
@@ -1381,31 +1409,28 @@ export function useSidebarSessions(directory?: string): Session[] {
|
|||||||
|
|
||||||
/** Get one session by id for a directory */
|
/** Get one session by id for a directory */
|
||||||
export function useSession(sessionID?: string | null, directory?: string) {
|
export function useSession(sessionID?: string | null, directory?: string) {
|
||||||
return useDirectorySync(
|
const { childStores } = useSyncSystem()
|
||||||
useCallback(
|
const getSnapshot = useCallback(() => {
|
||||||
(state: State) => {
|
if (directory) {
|
||||||
if (!sessionID) return undefined
|
return childStores.getChild(directory)?.getState().session.find((session) => session.id === sessionID)
|
||||||
return state.session.find((session) => session.id === sessionID)
|
}
|
||||||
},
|
return findLiveSession(getLiveStates(childStores), sessionID)
|
||||||
[sessionID],
|
}, [childStores, directory, sessionID])
|
||||||
),
|
|
||||||
directory,
|
const subscribe = useCallback((notify: () => void) => {
|
||||||
)
|
if (directory) {
|
||||||
|
return childStores.ensureChild(directory).subscribe(notify)
|
||||||
|
}
|
||||||
|
return childStores.subscribeAll(notify)
|
||||||
|
}, [childStores, directory])
|
||||||
|
|
||||||
|
return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get one session directory by id for a directory */
|
/** Get one session directory by id for a directory */
|
||||||
export function useSessionDirectory(sessionID?: string | null, directory?: string): string | undefined {
|
export function useSessionDirectory(sessionID?: string | null, directory?: string): string | undefined {
|
||||||
return useDirectorySync(
|
const session = useSession(sessionID, directory)
|
||||||
useCallback(
|
return (session as (typeof session & { directory?: string | null }) | undefined)?.directory ?? undefined
|
||||||
(state: State) => {
|
|
||||||
if (!sessionID) return undefined
|
|
||||||
const session = state.session.find((candidate) => candidate.id === sessionID)
|
|
||||||
return (session as (typeof session & { directory?: string | null }) | undefined)?.directory ?? undefined
|
|
||||||
},
|
|
||||||
[sessionID],
|
|
||||||
),
|
|
||||||
directory,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get the SDK client */
|
/** Get the SDK client */
|
||||||
@@ -1451,42 +1476,84 @@ const getFirstTextFromParts = (parts: Part[]): string => {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
function usePartsSnapshotForMessageIds(messageIds: string[], directory?: string, suspendUpdates = false) {
|
type SessionMessageRecord = { info: Message; parts: Part[] }
|
||||||
const store = useDirectoryStore(directory)
|
|
||||||
const prevPartsRef = useRef<Record<string, Part[]>>({})
|
|
||||||
const [partsSnapshot, setPartsSnapshot] = React.useState<Record<string, Part[]>>({})
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
type SessionMessageRecordsSnapshot = {
|
||||||
const flush = () => {
|
sessionID: string
|
||||||
const state = store.getState()
|
sourceMessages: Message[]
|
||||||
const prev = prevPartsRef.current
|
visibleMessages: Message[]
|
||||||
let changed = false
|
revertMessageID?: string
|
||||||
const next: Record<string, Part[]> = {}
|
list: SessionMessageRecord[]
|
||||||
for (const id of messageIds) {
|
byId: Map<string, SessionMessageRecord>
|
||||||
const parts = state.part[id] ?? EMPTY_PARTS
|
}
|
||||||
next[id] = prev[id] === parts ? prev[id] : parts
|
|
||||||
if (next[id] !== prev[id]) changed = true
|
function getVisibleMessagesForSession(state: State, sessionID: string, previous?: SessionMessageRecordsSnapshot): {
|
||||||
}
|
sourceMessages: Message[]
|
||||||
if (changed || Object.keys(prev).length !== messageIds.length) {
|
visibleMessages: Message[]
|
||||||
prevPartsRef.current = next
|
revertMessageID?: string
|
||||||
setPartsSnapshot(next)
|
} {
|
||||||
}
|
const sourceMessages = state.message[sessionID] ?? EMPTY_MESSAGES
|
||||||
|
const session = state.session.find((candidate) => candidate.id === sessionID)
|
||||||
|
const revertMessageID = (session as { revert?: { messageID?: string } } | undefined)?.revert?.messageID
|
||||||
|
|
||||||
|
if (
|
||||||
|
previous
|
||||||
|
&& previous.sourceMessages === sourceMessages
|
||||||
|
&& previous.revertMessageID === revertMessageID
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
sourceMessages,
|
||||||
|
visibleMessages: previous.visibleMessages,
|
||||||
|
revertMessageID,
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
flush()
|
return {
|
||||||
|
sourceMessages,
|
||||||
|
visibleMessages: revertMessageID ? sourceMessages.filter((message) => message.id < revertMessageID) : sourceMessages,
|
||||||
|
revertMessageID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (suspendUpdates) {
|
function buildSessionMessageRecordsSnapshot(
|
||||||
return
|
state: State,
|
||||||
}
|
sessionID: string,
|
||||||
|
previous?: SessionMessageRecordsSnapshot,
|
||||||
|
suspendPartUpdates = false,
|
||||||
|
): SessionMessageRecordsSnapshot {
|
||||||
|
const { sourceMessages, visibleMessages, revertMessageID } = getVisibleMessagesForSession(state, sessionID, previous)
|
||||||
|
const nextById = new Map<string, SessionMessageRecord>()
|
||||||
|
const nextList = visibleMessages.map((message) => {
|
||||||
|
const previousRecord = previous?.byId.get(message.id)
|
||||||
|
const parts = suspendPartUpdates && previousRecord
|
||||||
|
? previousRecord.parts
|
||||||
|
: (state.part[message.id] ?? EMPTY_PARTS)
|
||||||
|
|
||||||
const unsub = store.subscribe(flush)
|
const nextRecord = previousRecord && previousRecord.info === message && previousRecord.parts === parts
|
||||||
|
? previousRecord
|
||||||
|
: { info: message, parts }
|
||||||
|
|
||||||
return () => {
|
nextById.set(message.id, nextRecord)
|
||||||
unsub()
|
return nextRecord
|
||||||
}
|
})
|
||||||
}, [messageIds, store, suspendUpdates])
|
|
||||||
|
|
||||||
return partsSnapshot
|
const unchanged = Boolean(previous)
|
||||||
|
&& previous?.visibleMessages === visibleMessages
|
||||||
|
&& previous.list.length === nextList.length
|
||||||
|
&& previous.list.every((record, index) => record === nextList[index])
|
||||||
|
|
||||||
|
if (unchanged && previous) {
|
||||||
|
return previous
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionID,
|
||||||
|
sourceMessages,
|
||||||
|
visibleMessages,
|
||||||
|
revertMessageID,
|
||||||
|
list: nextList,
|
||||||
|
byId: nextById,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSessionMessageCount(sessionID: string, directory?: string): number {
|
export function useSessionMessageCount(sessionID: string, directory?: string): number {
|
||||||
@@ -1500,40 +1567,33 @@ export function useSessionMessageCount(sessionID: string, directory?: string): n
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useSessionTextMessages(sessionID: string, directory?: string): SessionTextMessage[] {
|
export function useSessionTextMessages(sessionID: string, directory?: string): SessionTextMessage[] {
|
||||||
const messages = useVisibleSessionMessages(sessionID, directory)
|
const records = useSessionMessageRecords(sessionID, directory)
|
||||||
const messageIds = useMemo(() => messages.map((message) => message.id), [messages])
|
|
||||||
const partsSnapshot = usePartsSnapshotForMessageIds(messageIds, directory)
|
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => messages.map((message) => ({
|
() => records.map((record) => ({
|
||||||
id: message.id,
|
id: record.info.id,
|
||||||
role: typeof message.role === "string" ? message.role : null,
|
role: typeof record.info.role === "string" ? record.info.role : null,
|
||||||
text: getConcatenatedTextFromParts(partsSnapshot[message.id] ?? EMPTY_PARTS),
|
text: getConcatenatedTextFromParts(record.parts),
|
||||||
})),
|
})),
|
||||||
[messages, partsSnapshot],
|
[records],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useUserMessageHistory(sessionID: string, directory?: string): string[] {
|
export function useUserMessageHistory(sessionID: string, directory?: string): string[] {
|
||||||
const messages = useVisibleSessionMessages(sessionID, directory)
|
const records = useSessionMessageRecords(sessionID, directory)
|
||||||
const userMessages = useMemo(
|
const userMessages = useMemo(() => records.filter((record) => record.info.role === 'user'), [records])
|
||||||
() => messages.filter((message) => message.role === "user"),
|
|
||||||
[messages],
|
|
||||||
)
|
|
||||||
const userMessageIds = useMemo(() => userMessages.map((message) => message.id), [userMessages])
|
|
||||||
const partsSnapshot = usePartsSnapshotForMessageIds(userMessageIds, directory)
|
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const history: string[] = []
|
const history: string[] = []
|
||||||
for (let index = userMessages.length - 1; index >= 0; index -= 1) {
|
for (let index = userMessages.length - 1; index >= 0; index -= 1) {
|
||||||
const message = userMessages[index]
|
const message = userMessages[index]
|
||||||
const text = getFirstTextFromParts(partsSnapshot[message.id] ?? EMPTY_PARTS)
|
const text = getFirstTextFromParts(message.parts)
|
||||||
if (text.length > 0) {
|
if (text.length > 0) {
|
||||||
history.push(text)
|
history.push(text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return history
|
return history
|
||||||
}, [partsSnapshot, userMessages])
|
}, [userMessages])
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1548,44 +1608,28 @@ export function useSessionMessageRecords(
|
|||||||
directory?: string,
|
directory?: string,
|
||||||
options?: { suspendPartUpdates?: boolean },
|
options?: { suspendPartUpdates?: boolean },
|
||||||
) {
|
) {
|
||||||
const messages = useVisibleSessionMessages(sessionID, directory)
|
const store = useDirectoryStore(directory)
|
||||||
const messageIds = useMemo(() => messages.map((message) => message.id), [messages])
|
const snapshotRef = useRef<SessionMessageRecordsSnapshot>({
|
||||||
const partsSnapshot = usePartsSnapshotForMessageIds(messageIds, directory, Boolean(options?.suspendPartUpdates))
|
sessionID,
|
||||||
const previousRecordsRef = useRef<{
|
sourceMessages: EMPTY_MESSAGES,
|
||||||
list: Array<{ info: (typeof messages)[number]; parts: Part[] }>
|
visibleMessages: EMPTY_MESSAGES,
|
||||||
byId: Map<string, { info: (typeof messages)[number]; parts: Part[] }>
|
revertMessageID: undefined,
|
||||||
}>({
|
|
||||||
list: [],
|
list: [],
|
||||||
byId: new Map(),
|
byId: new Map(),
|
||||||
})
|
})
|
||||||
|
|
||||||
return useMemo(() => {
|
const getSnapshot = useCallback(() => {
|
||||||
const previous = previousRecordsRef.current
|
const nextSnapshot = buildSessionMessageRecordsSnapshot(
|
||||||
const nextById = new Map<string, { info: (typeof messages)[number]; parts: Part[] }>()
|
store.getState(),
|
||||||
const nextList = messages.map((message) => {
|
sessionID,
|
||||||
const parts = partsSnapshot[message.id] ?? EMPTY_PARTS
|
snapshotRef.current.sessionID === sessionID ? snapshotRef.current : undefined,
|
||||||
const previousRecord = previous.byId.get(message.id)
|
Boolean(options?.suspendPartUpdates),
|
||||||
const record = previousRecord && previousRecord.info === message && previousRecord.parts === parts
|
)
|
||||||
? previousRecord
|
snapshotRef.current = nextSnapshot
|
||||||
: { info: message, parts }
|
return nextSnapshot.list
|
||||||
nextById.set(message.id, record)
|
}, [options?.suspendPartUpdates, sessionID, store])
|
||||||
return record
|
|
||||||
})
|
|
||||||
|
|
||||||
const unchanged = previous.list.length === nextList.length
|
return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot)
|
||||||
&& previous.list.every((record, index) => record === nextList[index])
|
|
||||||
|
|
||||||
if (unchanged) {
|
|
||||||
return previous.list
|
|
||||||
}
|
|
||||||
|
|
||||||
previousRecordsRef.current = {
|
|
||||||
list: nextList,
|
|
||||||
byId: nextById,
|
|
||||||
}
|
|
||||||
|
|
||||||
return nextList
|
|
||||||
}, [messages, partsSnapshot])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user