fix: keep session titles and lists in sync
Update sidebar titles from session events Load all session pages instead of partial lists Refresh sessions only for newly added project directories
This commit is contained in:
@@ -69,7 +69,13 @@ import {
|
||||
formatProjectLabel,
|
||||
normalizePath,
|
||||
} from './sidebar/utils';
|
||||
import { mergeSessionDirectoryMetadata, refreshGlobalSessions, resolveGlobalSessionDirectory, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||
import {
|
||||
mergeSessionDirectoryMetadata,
|
||||
refreshGlobalSessions,
|
||||
refreshGlobalSessionsForDirectories,
|
||||
resolveGlobalSessionDirectory,
|
||||
useGlobalSessionsStore,
|
||||
} from '@/stores/useGlobalSessionsStore';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||
import { subscribeOpenchamberEvents } from '@/lib/openchamberEvents';
|
||||
@@ -794,6 +800,36 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
[normalizedProjects],
|
||||
);
|
||||
|
||||
const projectSessionDirectories = React.useMemo(() => {
|
||||
const directories = new Set<string>();
|
||||
normalizedProjects.forEach((project) => {
|
||||
if (project.normalizedPath) directories.add(project.normalizedPath);
|
||||
const worktrees = availableWorktreesByProject.get(project.normalizedPath) ?? [];
|
||||
worktrees.forEach((worktree) => {
|
||||
const directory = normalizePath(worktree.path);
|
||||
if (directory) directories.add(directory);
|
||||
});
|
||||
});
|
||||
return [...directories].sort();
|
||||
}, [availableWorktreesByProject, normalizedProjects]);
|
||||
|
||||
const knownProjectSessionDirectoriesRef = React.useRef<Set<string> | null>(null);
|
||||
React.useEffect(() => {
|
||||
const nextDirectories = new Set(projectSessionDirectories);
|
||||
const previousDirectories = knownProjectSessionDirectoriesRef.current;
|
||||
knownProjectSessionDirectoriesRef.current = nextDirectories;
|
||||
if (!previousDirectories) {
|
||||
return;
|
||||
}
|
||||
|
||||
const addedDirectories = projectSessionDirectories.filter((directory) => !previousDirectories.has(directory));
|
||||
if (addedDirectories.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
void refreshGlobalSessionsForDirectories(addedDirectories, syncSessionsSnapshotRef.current);
|
||||
}, [projectSessionDirectories]);
|
||||
|
||||
const { github } = useRuntimeAPIs();
|
||||
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
|
||||
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
|
||||
|
||||
@@ -37,6 +37,41 @@ const readResponseHeader = (response: unknown, header: string): string | null =>
|
||||
return typeof direct === "string" ? direct : null;
|
||||
};
|
||||
|
||||
const formatSdkError = (error: unknown): string => {
|
||||
if (error instanceof Error) return error.message;
|
||||
if (typeof error === "string") return error;
|
||||
if (error && typeof error === "object" && "message" in error && typeof (error as { message?: unknown }).message === "string") {
|
||||
return (error as { message: string }).message;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(error);
|
||||
} catch {
|
||||
return String(error);
|
||||
}
|
||||
};
|
||||
|
||||
const unwrapSessionList = (
|
||||
result: { data?: Session[]; error?: unknown; response?: { status?: number } },
|
||||
operation: string,
|
||||
): GlobalSessionRecord[] => {
|
||||
if (result.error) {
|
||||
const status = result.response?.status;
|
||||
const error = new Error(`${operation} failed${status ? ` (${status})` : ""}: ${formatSdkError(result.error)}`);
|
||||
if (status !== undefined) {
|
||||
(error as Error & { status?: number }).status = status;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!Array.isArray(result.data)) {
|
||||
const error = new Error(`${operation} returned no data`);
|
||||
(error as Error & { status?: number }).status = 503;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return result.data as GlobalSessionRecord[];
|
||||
};
|
||||
|
||||
export const readNextCursor = (response: unknown): number | null => {
|
||||
return toNumber(readResponseHeader(response, "x-next-cursor"));
|
||||
};
|
||||
@@ -63,7 +98,9 @@ export const isMissingGlobalSessionsEndpointError = (error: unknown): boolean =>
|
||||
export async function listGlobalSessionPages(
|
||||
apiClient: OpencodeClient,
|
||||
options: {
|
||||
directory?: string;
|
||||
archived: boolean;
|
||||
roots?: boolean;
|
||||
pageSize: number;
|
||||
onPage?: (sessions: GlobalSessionRecord[]) => void;
|
||||
},
|
||||
@@ -75,14 +112,16 @@ export async function listGlobalSessionPages(
|
||||
while (true) {
|
||||
const response = await retry(
|
||||
() => apiClient.experimental.session.list({
|
||||
...(options.directory ? { directory: options.directory } : {}),
|
||||
archived: options.archived,
|
||||
...(options.roots !== undefined ? { roots: options.roots } : {}),
|
||||
limit: options.pageSize,
|
||||
...(cursor !== undefined ? { cursor } : {}),
|
||||
}),
|
||||
{ attempts: 3, delay: 500, retryIf: () => true },
|
||||
);
|
||||
|
||||
const payload = Array.isArray(response.data) ? (response.data as GlobalSessionRecord[]) : [];
|
||||
const payload = unwrapSessionList(response, "experimental.session.list");
|
||||
if (payload.length === 0) break;
|
||||
|
||||
let appended = 0;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { create } from 'zustand';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import type { OpencodeClient, Session } from '@opencode-ai/sdk/v2';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { listGlobalSessionPages } from '@/stores/globalSessions';
|
||||
|
||||
@@ -17,6 +17,7 @@ type GlobalSessionsState = {
|
||||
hasLoaded: boolean;
|
||||
status: GlobalSessionsStatus;
|
||||
loadSessions: (fallbackActive?: Session[]) => Promise<LoadResult>;
|
||||
refreshSessionsForDirectories: (directories: Iterable<string>, fallbackActive?: Session[]) => Promise<LoadResult>;
|
||||
applySnapshot: (activeSessions: Session[], archivedSessions: Session[], status?: GlobalSessionsStatus) => void;
|
||||
upsertSession: (session: Session) => void;
|
||||
removeSessions: (ids: Iterable<string>) => void;
|
||||
@@ -83,10 +84,14 @@ export const mergeSessionDirectoryMetadata = (incoming: Session, existing?: Sess
|
||||
|
||||
if (!incomingWorktree && existingWorktree) {
|
||||
next.project = {
|
||||
...(existingRecord.project ?? {}),
|
||||
...(incomingRecord.project ?? {}),
|
||||
worktree: existingRecord.project?.worktree,
|
||||
};
|
||||
changed = true;
|
||||
} else if (!incomingRecord.project && existingRecord.project) {
|
||||
next.project = existingRecord.project;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed ? next : incoming;
|
||||
@@ -136,6 +141,92 @@ const sameSessionList = (prev: Session[], next: Session[]): boolean => {
|
||||
return true;
|
||||
};
|
||||
|
||||
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 sortSessionsByUpdated = (sessions: Session[]): Session[] => {
|
||||
return [...sessions].sort((left, right) => {
|
||||
const timeDelta = getSessionUpdatedAt(right) - getSessionUpdatedAt(left);
|
||||
if (timeDelta !== 0) return timeDelta;
|
||||
return right.id.localeCompare(left.id);
|
||||
});
|
||||
};
|
||||
|
||||
const normalizeDirectorySet = (directories: Iterable<string>): Set<string> => {
|
||||
const next = new Set<string>();
|
||||
for (const directory of directories) {
|
||||
const normalized = normalizePath(directory);
|
||||
if (normalized) next.add(normalized);
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
const replaceSessionsForDirectories = (
|
||||
existing: Session[],
|
||||
incoming: Session[],
|
||||
directories: Set<string>,
|
||||
): Session[] => {
|
||||
if (directories.size === 0) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const existingById = new Map(existing.map((session) => [session.id, session]));
|
||||
const incomingById = new Map<string, Session>();
|
||||
|
||||
for (const session of incoming) {
|
||||
if (!session?.id) continue;
|
||||
incomingById.set(session.id, mergeSessionDirectoryMetadata(session, existingById.get(session.id)));
|
||||
}
|
||||
|
||||
const kept = existing.filter((session) => {
|
||||
if (incomingById.has(session.id)) return false;
|
||||
const directory = resolveGlobalSessionDirectory(session);
|
||||
return !directory || !directories.has(directory);
|
||||
});
|
||||
|
||||
return sortSessionsByUpdated([...incomingById.values(), ...kept]);
|
||||
};
|
||||
|
||||
type DirectoryPageResult = {
|
||||
directories: Set<string>;
|
||||
sessions: Session[];
|
||||
errors: unknown[];
|
||||
};
|
||||
|
||||
const fetchDirectoryPages = async (
|
||||
sdk: OpencodeClient,
|
||||
directories: Set<string>,
|
||||
archived: boolean,
|
||||
): Promise<DirectoryPageResult> => {
|
||||
const results = await Promise.allSettled(
|
||||
[...directories].map(async (directory) => ({
|
||||
directory,
|
||||
sessions: await listGlobalSessionPages(sdk, { directory, archived, pageSize: PAGE_SIZE }),
|
||||
})),
|
||||
);
|
||||
|
||||
const fulfilledDirectories = new Set<string>();
|
||||
const sessions: Session[] = [];
|
||||
const errors: unknown[] = [];
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled') {
|
||||
fulfilledDirectories.add(result.value.directory);
|
||||
sessions.push(...result.value.sessions);
|
||||
} else {
|
||||
errors.push(result.reason);
|
||||
}
|
||||
}
|
||||
|
||||
return { directories: fulfilledDirectories, sessions, errors };
|
||||
};
|
||||
|
||||
const upsertSessionIntoList = (sessions: Session[], session: Session): Session[] => {
|
||||
const index = sessions.findIndex((candidate) => candidate.id === session.id);
|
||||
if (index === -1) {
|
||||
@@ -284,6 +375,61 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
|
||||
return inflightLoad;
|
||||
},
|
||||
|
||||
refreshSessionsForDirectories: async (directories, fallbackActive) => {
|
||||
const directorySet = normalizeDirectorySet(directories);
|
||||
if (directorySet.size === 0) {
|
||||
const state = get();
|
||||
return { activeSessions: state.activeSessions, archivedSessions: state.archivedSessions };
|
||||
}
|
||||
|
||||
const sdk = opencodeClient.getSdkClient();
|
||||
const [active, archived] = await Promise.all([
|
||||
fetchDirectoryPages(sdk, directorySet, false),
|
||||
fetchDirectoryPages(sdk, directorySet, true),
|
||||
]);
|
||||
|
||||
if (active.errors.length > 0) {
|
||||
console.warn('[GlobalSessions] Failed to refresh active sessions for some directories:', active.errors[0]);
|
||||
}
|
||||
if (archived.errors.length > 0) {
|
||||
console.warn('[GlobalSessions] Failed to refresh archived sessions for some directories:', archived.errors[0]);
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
let nextActiveSessions = replaceSessionsForDirectories(state.activeSessions, active.sessions, active.directories);
|
||||
nextActiveSessions = mergeSessionLists(nextActiveSessions, fallbackActive);
|
||||
if (sameSessionList(state.activeSessions, nextActiveSessions)) {
|
||||
nextActiveSessions = state.activeSessions;
|
||||
}
|
||||
|
||||
let nextArchivedSessions = replaceSessionsForDirectories(state.archivedSessions, archived.sessions, archived.directories);
|
||||
if (sameSessionList(state.archivedSessions, nextArchivedSessions)) {
|
||||
nextArchivedSessions = state.archivedSessions;
|
||||
}
|
||||
|
||||
const nextSessionsByDirectory = nextActiveSessions === state.activeSessions
|
||||
? state.sessionsByDirectory
|
||||
: buildSessionsByDirectory(nextActiveSessions);
|
||||
|
||||
if (
|
||||
nextActiveSessions === state.activeSessions
|
||||
&& nextArchivedSessions === state.archivedSessions
|
||||
&& nextSessionsByDirectory === state.sessionsByDirectory
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
activeSessions: nextActiveSessions,
|
||||
archivedSessions: nextArchivedSessions,
|
||||
sessionsByDirectory: nextSessionsByDirectory,
|
||||
};
|
||||
});
|
||||
|
||||
const state = get();
|
||||
return { activeSessions: state.activeSessions, archivedSessions: state.archivedSessions };
|
||||
},
|
||||
|
||||
upsertSession: (session) => {
|
||||
set((state) => {
|
||||
const existingSession = state.activeSessions.find((candidate) => candidate.id === session.id)
|
||||
@@ -392,3 +538,10 @@ export const ensureGlobalSessionsLoaded = async (fallbackActive?: Session[]): Pr
|
||||
export const refreshGlobalSessions = async (fallbackActive?: Session[]): Promise<LoadResult> => {
|
||||
return useGlobalSessionsStore.getState().loadSessions(fallbackActive);
|
||||
};
|
||||
|
||||
export const refreshGlobalSessionsForDirectories = async (
|
||||
directories: Iterable<string>,
|
||||
fallbackActive?: Session[],
|
||||
): Promise<LoadResult> => {
|
||||
return useGlobalSessionsStore.getState().refreshSessionsForDirectories(directories, fallbackActive);
|
||||
};
|
||||
|
||||
@@ -257,6 +257,10 @@ export function createEventPipeline(input: EventPipelineInput): EventPipeline {
|
||||
const props = payload.properties as { sessionID: string }
|
||||
return `session.status:${props.sessionID}`
|
||||
}
|
||||
if (payload.type === "session.updated") {
|
||||
const props = payload.properties as { info?: { id?: string } }
|
||||
return props.info?.id ? `session.updated:${props.info.id}` : undefined
|
||||
}
|
||||
if (payload.type === "lsp.updated") {
|
||||
return "lsp.updated"
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@ import { getRuntimeLiveStatusSeed, LIVE_STATUS_TTL_MS } from "./runtime-live-mem
|
||||
import { getRuntimeKey } from "@/lib/runtime-switch"
|
||||
import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry"
|
||||
import { setSessionPrefetch } from "./session-prefetch-cache"
|
||||
import { listGlobalSessionPages } from "@/stores/globalSessions"
|
||||
import { useGlobalSessionsStore } from "@/stores/useGlobalSessionsStore"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context
|
||||
@@ -589,6 +591,46 @@ const getSessionIdFromPayload = (event: Event): string | null => {
|
||||
return null
|
||||
}
|
||||
|
||||
const getSessionInfoFromPayload = (event: Event): Session | null => {
|
||||
if (event.type !== "session.created" && event.type !== "session.updated" && event.type !== "session.deleted") {
|
||||
return null
|
||||
}
|
||||
|
||||
const properties = (event as { properties?: unknown }).properties
|
||||
if (!properties || typeof properties !== "object") {
|
||||
return null
|
||||
}
|
||||
|
||||
const info = (properties as { info?: unknown }).info
|
||||
if (!info || typeof info !== "object") {
|
||||
return null
|
||||
}
|
||||
|
||||
const session = info as Partial<Session>
|
||||
if (typeof session.id !== "string" || !session.time) {
|
||||
return null
|
||||
}
|
||||
|
||||
return stripSessionDiffSnapshots(session as Session)
|
||||
}
|
||||
|
||||
const applySessionEventToGlobalSessions = (payload: Event) => {
|
||||
if (payload.type === "session.created" || payload.type === "session.updated") {
|
||||
const session = getSessionInfoFromPayload(payload)
|
||||
if (session) {
|
||||
useGlobalSessionsStore.getState().upsertSession(session)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (payload.type === "session.deleted") {
|
||||
const sessionID = getSessionIdFromPayload(payload) ?? getSessionInfoFromPayload(payload)?.id
|
||||
if (sessionID) {
|
||||
useGlobalSessionsStore.getState().removeSessions([sessionID])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getMessageIdFromPayload = (event: Event): string | null => {
|
||||
const properties = (event as { properties?: unknown }).properties
|
||||
if (!properties || typeof properties !== "object") {
|
||||
@@ -1205,6 +1247,8 @@ function handleEvent(
|
||||
return
|
||||
}
|
||||
|
||||
applySessionEventToGlobalSessions(payload)
|
||||
|
||||
// Global events
|
||||
if (directory === "global" || !directory) {
|
||||
const recent = isRecentBoot()
|
||||
@@ -1561,27 +1605,12 @@ export function SyncProvider(props: {
|
||||
providers: globalState.providers,
|
||||
},
|
||||
loadSessions: (dir) => retry(async () => {
|
||||
const result = await props.sdk.session.list({
|
||||
const sessions = (await listGlobalSessionPages(props.sdk, {
|
||||
directory: dir,
|
||||
archived: false,
|
||||
roots: true,
|
||||
limit: 50,
|
||||
})
|
||||
// SDK returns { error } instead of { data } on non-ok responses (503).
|
||||
// Preserve HTTP status so retry()'s transient detection works.
|
||||
const rawError = (result as { error?: unknown }).error
|
||||
if (rawError) {
|
||||
const response = (result as { response?: { status?: number } }).response
|
||||
const status = response?.status
|
||||
const message = typeof rawError === "object" && rawError !== null && "message" in rawError
|
||||
? String((rawError as { message?: unknown }).message)
|
||||
: String(rawError)
|
||||
const wrapped = new Error(`session.list failed${status ? ` (${status})` : ""}: ${message}`)
|
||||
if (status !== undefined) {
|
||||
;(wrapped as Error & { status?: number }).status = status
|
||||
}
|
||||
throw wrapped
|
||||
}
|
||||
const sessions = (result.data ?? [])
|
||||
pageSize: 500,
|
||||
}))
|
||||
.filter((s) => !!s?.id)
|
||||
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
||||
// Race guard: if the list came back empty but event pipeline
|
||||
@@ -1591,7 +1620,7 @@ export function SyncProvider(props: {
|
||||
const currentSessions = store.getState().session
|
||||
if (sessions.length === 0 && currentSessions.length > 0) {
|
||||
console.warn(
|
||||
`[bootstrap] session.list returned empty for ${dir}; preserving ${currentSessions.length} existing sessions`,
|
||||
`[bootstrap] experimental.session.list returned empty for ${dir}; preserving ${currentSessions.length} existing sessions`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user