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.
This commit is contained in:
Bohdan Triapitsyn
2026-08-21 12:12:40 +03:00
parent 0d70a631f6
commit 9e87d7fdb9
46 changed files with 677 additions and 136 deletions
+24 -2
View File
@@ -1,7 +1,29 @@
import { describe, expect, test } from 'bun:test'
import type { OpencodeClient } from '@opencode-ai/sdk/v2'
import type { OpencodeClient, Session } from '@opencode-ai/sdk/v2'
import { listGlobalSessionPages, splitGlobalSessionsByArchived } from './globalSessions'
import { filterManagedChatsForRuntime, listGlobalSessionPages, splitGlobalSessionsByArchived } from './globalSessions'
describe('managed Chats runtime visibility', () => {
const session = (id: string, directory: string): Session => ({
id,
slug: id,
projectID: 'project',
directory,
title: id,
version: '1',
time: { created: 1, updated: 1 },
})
const chat = session('chat', '/home/user/.config/openchamber/chats/2026-08-21/session-a')
const project = session('project', '/workspace/project')
test('VS Code rejects managed Chats before they enter global state', () => {
expect(filterManagedChatsForRuntime([chat, project], true)).toEqual([project])
})
test('other runtimes retain managed Chats', () => {
expect(filterManagedChatsForRuntime([chat, project], false)).toEqual([chat, project])
})
})
describe('listGlobalSessionPages', () => {
test('sanitizes session list records before returning them', async () => {
+7
View File
@@ -3,6 +3,7 @@ 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?: {
@@ -12,6 +13,12 @@ export type GlobalSessionRecord = Session & {
} | 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;
@@ -1,12 +1,14 @@
import { create } from 'zustand';
import type { OpencodeClient, Session } from '@opencode-ai/sdk/v2';
import { opencodeClient } from '@/lib/opencode/client';
import { listGlobalSessionPages, splitGlobalSessionsByArchived } from '@/stores/globalSessions';
import { filterManagedChatsForRuntime, listGlobalSessionPages, splitGlobalSessionsByArchived } from '@/stores/globalSessions';
import { getReviewTransferDirection, type ReviewTransferDirection } from '@/lib/reviewFlow';
import { getOriginalSessionID, getReviewSessionID } from '@/lib/sessionReviewMetadata';
import { normalizePath } from '@/lib/pathNormalization';
import { raiseSessionOrderingBaselines } from '@/sync/session-ordering';
import { mapWithConcurrency } from '@/lib/concurrency';
import { persistManagedChatSessions, readManagedChatSessions } from '@/sync/persist-cache';
import { isVSCodeRuntime } from '@/lib/desktop';
type GlobalSessionsStatus = 'idle' | 'loading' | 'ready' | 'error';
@@ -363,6 +365,10 @@ const applySnapshot = (
archivedSessions: Session[],
status: GlobalSessionsStatus,
): Partial<GlobalSessionsState> | GlobalSessionsState => {
if (isVSCodeRuntime()) {
activeSessions = filterManagedChatsForRuntime(activeSessions, true);
archivedSessions = filterManagedChatsForRuntime(archivedSessions, true);
}
const nextActiveSessions = sameSessionList(state.activeSessions, activeSessions)
? state.activeSessions
: activeSessions;
@@ -430,6 +436,10 @@ const mutationRevisionPatch = (state: GlobalSessionsState, ids: Iterable<string>
};
const applySessionUpserts = (state: GlobalSessionsState, sessions: Session[]): Partial<GlobalSessionsState> => {
if (isVSCodeRuntime()) {
sessions = filterManagedChatsForRuntime(sessions, true);
if (sessions.length === 0) return state;
}
const revisionPatch = mutationRevisionPatch(state, sessions.map((session) => session.id));
let nextActiveSessions = state.activeSessions;
let nextArchivedSessions = state.archivedSessions;
@@ -483,11 +493,13 @@ const buildReviewTransferMap = (sessions: Session[]): Map<string, ReviewTransfer
return next
}
const initialManagedChatSessions = readManagedChatSessions();
export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) => ({
activeSessions: [],
activeSessions: initialManagedChatSessions,
archivedSessions: [],
sessionsByDirectory: new Map(),
reviewTransferBySessionId: new Map(),
sessionsByDirectory: buildSessionsByDirectory(initialManagedChatSessions),
reviewTransferBySessionId: buildReviewTransferMap(initialManagedChatSessions),
mutationRevision: 0,
mutationRevisionBySessionId: new Map(),
hasLoaded: false,
@@ -504,11 +516,12 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
resetForRuntimeSwitch: () => {
loadGeneration += 1;
inflightLoad = null;
const managedChatSessions = readManagedChatSessions();
set({
activeSessions: [],
activeSessions: managedChatSessions,
archivedSessions: [],
sessionsByDirectory: new Map(),
reviewTransferBySessionId: new Map(),
sessionsByDirectory: buildSessionsByDirectory(managedChatSessions),
reviewTransferBySessionId: buildReviewTransferMap(managedChatSessions),
mutationRevision: 0,
mutationRevisionBySessionId: new Map(),
hasLoaded: false,
@@ -722,6 +735,15 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
},
}));
useGlobalSessionsStore.subscribe((state, previous) => {
if (
state.activeSessions !== previous.activeSessions
&& (state.status !== 'idle' || state.activeSessions.length > 0)
) {
persistManagedChatSessions(state.activeSessions);
}
});
export const ensureGlobalSessionsLoaded = async (fallbackActive?: Session[]): Promise<LoadResult> => {
const state = useGlobalSessionsStore.getState();
if (state.hasLoaded && state.status !== 'error') {