Files
openchamber/packages/ui/src/stores/globalSessions.ts
T
Bohdan Triapitsyn 9e87d7fdb9 feat(chats): add managed projectless chat sessions
Create projectless chat sessions under a managed, date-scoped Chats directory and clean abandoned or deleted session folders.

Add Chats to sidebar state, startup cache, shared context, and Electron Mini Chat while keeping VS Code project-only. Resolve managed chat directories to one server-side memory owner and document the runtime contracts.
2026-08-21 12:12:40 +03:00

216 lines
8.4 KiB
TypeScript

import type { OpencodeClient, Session } from "@opencode-ai/sdk/v2";
import { runBackgroundNetworkTask } from '@/lib/background-network';
import { retry } from "@/sync/retry";
import { stripSessionListDetails } from "@/sync/sanitize";
import { startSessionLoadPerformanceEvent } from "@/sync/session-load-performance";
import { isChatDirectoryPath } from '@/lib/chatDirectories';
export type GlobalSessionRecord = Session & {
project?: {
id: string;
name?: string;
worktree?: string;
} | null;
};
export const filterManagedChatsForRuntime = (sessions: Session[], vscode: boolean): Session[] => (
vscode
? sessions.filter((session) => !isChatDirectoryPath(session.directory))
: sessions
);
const toNumber = (value: string | null): number | null => {
if (!value) {
return null;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
};
const readResponseHeader = (response: unknown, header: string): string | null => {
if (!response || typeof response !== "object") {
return null;
}
const container = response as { headers?: unknown };
const headers = container.headers;
if (!headers || typeof headers !== "object") {
return null;
}
const maybeGet = headers as { get?: (name: string) => string | null };
if (typeof maybeGet.get === "function") {
return maybeGet.get(header);
}
const maybeRecord = headers as Record<string, unknown>;
const direct = maybeRecord[header] ?? maybeRecord[header.toLowerCase()];
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[];
};
/**
* OpenCode's `archived` query flag means "also include archived sessions", not
* "return only archived sessions": the server simply drops its
* `time_archived IS NULL` condition. Callers that ask for the archived list
* expect archived-only records, so narrow the response here, at the data
* boundary, instead of leaving every consumer to re-derive it.
*/
const isArchivedSession = (session: GlobalSessionRecord): boolean => Boolean(session.time?.archived);
/**
* Split an inclusive (`archived: true`) session page stream into active and
* archived buckets. Restored sessions carry `time.archived === 0` (see
* `UNARCHIVED_TIMESTAMP` in `sync/session-actions.ts`); the truthiness check
* classifies them as active even though the server's own
* `time_archived IS NULL` filter would still exclude them, which is why the
* global cache must split client-side instead of issuing an
* `archived: false` request for its active list.
*/
export const splitGlobalSessionsByArchived = <T extends GlobalSessionRecord>(
sessions: T[],
): { active: T[]; archived: T[] } => {
const active: T[] = [];
const archived: T[] = [];
for (const session of sessions) {
if (isArchivedSession(session)) archived.push(session);
else active.push(session);
}
return { active, archived };
};
export async function listGlobalSessionPages(
apiClient: OpencodeClient,
options: {
directory?: string;
archived: boolean;
/**
* When `archived` is true, narrow results to records carrying a truthy
* `time.archived` (default true). Pass false to receive the inclusive
* server response unfiltered, e.g. to split active/archived locally.
*/
narrowToArchived?: boolean;
roots?: boolean;
pageSize: number;
onPage?: (sessions: GlobalSessionRecord[]) => void;
},
): Promise<GlobalSessionRecord[]> {
const all: GlobalSessionRecord[] = [];
const seenIds = new Set<string>();
let cursor: number | undefined;
const narrowToArchived = options.narrowToArchived !== false;
let operation: string;
if (!options.directory) {
operation = `global-sessions.${options.archived ? (narrowToArchived ? "archived" : "all") : "active"}`;
} else if (options.roots === true) {
operation = "bootstrap.sessions.roots";
} else if (options.archived) {
operation = narrowToArchived ? "bootstrap.sessions.archived" : "bootstrap.sessions.all";
} else {
operation = "bootstrap.sessions.all";
}
while (true) {
let attempts = 0;
const finishPerformanceEvent = startSessionLoadPerformanceEvent({
operation,
caller: cursor === undefined ? "initial-page" : "pagination",
});
const { response, payload } = await runBackgroundNetworkTask(() => retry(
async () => {
attempts += 1;
const response = await apiClient.experimental.session.list({
...(options.directory ? { directory: options.directory } : {}),
archived: options.archived,
...(options.roots !== undefined ? { roots: options.roots } : {}),
limit: options.pageSize,
...(cursor !== undefined ? { cursor } : {}),
});
const payload = unwrapSessionList(response, "experimental.session.list")
.map((session) => stripSessionListDetails(session) as GlobalSessionRecord);
return { response, payload };
},
{ attempts: 3, delay: 500, retryIf: () => true },
)).catch((error) => {
finishPerformanceEvent("error", { retryCount: Math.max(0, attempts - 1) });
throw error;
});
finishPerformanceEvent("complete", {
retryCount: Math.max(0, attempts - 1),
recordCount: payload.length,
});
if (payload.length === 0) break;
// `appended` tracks pagination progress over the raw response, while
// `accepted` holds the records this call actually returns. Filtering
// must not feed the pagination guards below, otherwise a page that is
// full upstream but mostly non-archived would look like a last page.
let appended = 0;
const accepted: GlobalSessionRecord[] = [];
for (const session of payload) {
if (!session?.id || seenIds.has(session.id)) continue;
seenIds.add(session.id);
appended += 1;
if (options.archived && narrowToArchived && !isArchivedSession(session)) continue;
all.push(session);
accepted.push(session);
}
if (accepted.length > 0) {
options.onPage?.(accepted);
}
// Stop on partial page — nothing more to fetch.
if (payload.length < options.pageSize) break;
// Prefer server header; fall back to last session's `time.updated`
// (cursor semantics on server = "updated strictly before this timestamp").
const headerCursor = toNumber(readResponseHeader(response, "x-next-cursor"));
const lastUpdated = payload[payload.length - 1]?.time?.updated;
const nextCursor = headerCursor
?? (typeof lastUpdated === "number" && Number.isFinite(lastUpdated) ? lastUpdated : undefined);
if (nextCursor === undefined) break;
// Loop guard: cursor must move backwards in time.
if (cursor !== undefined && nextCursor >= cursor) break;
// Every id in this page already seen — stop to avoid spinning.
if (appended === 0) break;
cursor = nextCursor;
}
return all;
}