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
@@ -0,0 +1,55 @@
import { beforeEach, describe, expect, mock, test } from 'bun:test';
const createdDirectories: string[] = [];
const createDirectoryOptions: Array<{ allowOutsideWorkspace?: boolean } | undefined> = [];
const deletedDirectories: string[] = [];
mock.module('@/lib/opencode/client', () => ({
opencodeClient: {
getFilesystemHome: mock(async () => '/Users/tester'),
createDirectory: mock(async (path: string, options?: { allowOutsideWorkspace?: boolean }) => {
createdDirectories.push(path);
createDirectoryOptions.push(options);
return { success: true, path };
}),
},
}));
mock.module('@/lib/runtime-fetch', () => ({
runtimeFetch: mock(async (_path: string, init?: RequestInit) => {
deletedDirectories.push(JSON.parse(String(init?.body)).path);
return new Response(null, { status: 200 });
}),
}));
const { createChatDirectory, deleteChatDirectory, getChatsRootFromDirectory, isChatDirectoryForHome, isChatDirectoryPath } = await import('./chatDirectories');
describe('chat directories', () => {
beforeEach(() => {
createdDirectories.length = 0;
createDirectoryOptions.length = 0;
deletedDirectories.length = 0;
});
test('creates one isolated directory beneath the dated chats root', async () => {
const directory = await createChatDirectory(new Date(2026, 7, 21, 12));
expect(createdDirectories[0]).toBe(directory);
expect(directory.startsWith('/Users/tester/.config/openchamber/chats/2026-08-21/session-')).toBe(true);
expect(createdDirectories).toEqual([directory]);
expect(createDirectoryOptions).toEqual([undefined]);
});
test('recognizes only descendants of the managed chats root', () => {
expect(isChatDirectoryForHome('/Users/tester/.config/openchamber/chats/2026-08-21/session-a', '/Users/tester')).toBe(true);
expect(isChatDirectoryForHome('/Users/tester/project', '/Users/tester')).toBe(false);
expect(isChatDirectoryForHome('/remote/home/.config/openchamber/chats/2026-08-21/session-a', '/Users/tester')).toBe(true);
expect(isChatDirectoryPath('/remote/home/.config/openchamber/chats/2026-08-21/session-a')).toBe(true);
expect(getChatsRootFromDirectory('/remote/home/.config/openchamber/chats/2026-08-21/session-a')).toBe('/remote/home/.config/openchamber/chats');
});
test('deletes managed chat directories but leaves project directories alone', async () => {
await deleteChatDirectory('/Users/tester/.config/openchamber/chats/2026-08-21/session-a');
await deleteChatDirectory('/Users/tester/project');
expect(deletedDirectories).toEqual(['/Users/tester/.config/openchamber/chats/2026-08-21/session-a']);
});
});
+88
View File
@@ -0,0 +1,88 @@
import { opencodeClient } from '@/lib/opencode/client';
import { normalizePath } from '@/lib/pathNormalization';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { getRuntimeKey } from '@/lib/runtime-switch';
export const CHAT_DRAFT_PROJECT_ID = 'openchamber:chats';
const MANAGED_CHATS_PATH_SEGMENT = '/.config/openchamber/chats/';
const chatsRootByRuntime = new Map<string, Promise<string>>();
const joinPath = (base: string, ...parts: string[]): string => {
const separator = base.includes('\\') ? '\\' : '/';
return [base.replace(/[\\/]+$/, ''), ...parts].join(separator);
};
export function isChatDirectoryForHome(directory: string | null | undefined, home: string | null | undefined): boolean {
const normalized = normalizePath(directory ?? null);
if (normalized?.includes(MANAGED_CHATS_PATH_SEGMENT)) return true;
const normalizedHome = normalizePath(home ?? null);
if (!normalized || !normalizedHome) return false;
const root = normalizePath(joinPath(normalizedHome, '.config', 'openchamber', 'chats'));
return Boolean(root && normalized.startsWith(`${root}/`));
}
export function isChatDirectoryPath(directory: string | null | undefined): boolean {
return normalizePath(directory ?? null)?.includes(MANAGED_CHATS_PATH_SEGMENT) === true;
}
export function getChatsRootFromDirectory(directory: string | null | undefined): string | null {
const normalized = normalizePath(directory ?? null);
const index = normalized?.indexOf(MANAGED_CHATS_PATH_SEGMENT) ?? -1;
return normalized && index >= 0
? normalized.slice(0, index + MANAGED_CHATS_PATH_SEGMENT.length - 1)
: null;
}
export function getChatsRootForHome(home: string | null | undefined): string | null {
const normalizedHome = normalizePath(home ?? null);
return normalizedHome ? normalizePath(joinPath(normalizedHome, '.config', 'openchamber', 'chats')) : null;
}
async function getChatsRootDirectory(): Promise<string> {
const runtimeKey = getRuntimeKey();
const existing = chatsRootByRuntime.get(runtimeKey);
if (existing) return existing;
const pending = opencodeClient.getFilesystemHome().then((home) => {
if (!home) throw new Error('Unable to resolve the home directory');
return joinPath(home, '.config', 'openchamber', 'chats');
}).catch((error) => {
chatsRootByRuntime.delete(runtimeKey);
throw error;
});
chatsRootByRuntime.set(runtimeKey, pending);
return pending;
}
export function warmChatsRootDirectory(): void {
void getChatsRootDirectory().catch(() => undefined);
}
export async function createChatDirectory(now = new Date()): Promise<string> {
const root = await getChatsRootDirectory();
const date = [now.getFullYear(), String(now.getMonth() + 1).padStart(2, '0'), String(now.getDate()).padStart(2, '0')].join('-');
const dateDirectory = joinPath(root, date);
const id = globalThis.crypto?.randomUUID?.() ?? `${now.getTime()}-${Math.random().toString(36).slice(2)}`;
const directory = joinPath(dateDirectory, `session-${id}`);
await opencodeClient.createDirectory(directory);
return directory;
}
async function isChatDirectory(directory: string | null | undefined): Promise<boolean> {
const normalized = normalizePath(directory ?? null);
if (!normalized) return false;
const root = normalizePath(await getChatsRootDirectory());
return Boolean(root && (normalized === root || normalized.startsWith(`${root}/`)));
}
export async function deleteChatDirectory(directory: string): Promise<void> {
if (!await isChatDirectory(directory)) return;
const response = await runtimeFetch('/api/fs/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: directory }),
});
if (!response.ok && response.status !== 404) {
throw new Error(`Failed to delete chat directory (${response.status})`);
}
}
+2
View File
@@ -415,6 +415,8 @@ export const dict = {
'sessions.sidebar.empty.noMatches.title': 'Keine passenden Sitzungen',
'sessions.sidebar.empty.noMatches.description': 'Versuchen Sie einen anderen Titel, Branch, Ordner oder Pfad.',
'sessions.sidebar.activity.recentTitle': 'kürzlich',
'sessions.sidebar.activity.chatsTitle': 'Chats',
'chat.chatInput.chooseProject': 'Projekt auswählen',
'sessions.switcher.openAria': 'Sitzungswechsler öffnen',
'sessions.switcher.empty': 'Keine kürzlichen Sitzungen',
'sessions.switcher.draftTitle': 'Neue Sitzung',
+2
View File
@@ -437,6 +437,8 @@ export const dict = {
'sessions.sidebar.empty.noMatches.title': 'No matching sessions',
'sessions.sidebar.empty.noMatches.description': 'Try a different title, branch, folder, or path.',
'sessions.sidebar.activity.recentTitle': 'recent',
'sessions.sidebar.activity.chatsTitle': 'chats',
'chat.chatInput.chooseProject': 'Choose project',
'sessions.archivePage.allDirectories': 'All directories',
'sessions.sidebar.header.displayMode.stickyHeaders': 'Sticky project headers',
'sessions.sidebar.header.grouping.label': 'Group sessions',
+2
View File
@@ -438,6 +438,8 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.empty.noMatches.title": "No hay sesiones coincidentes",
"sessions.sidebar.empty.noMatches.description": "Inténtalo con un título, rama, carpeta o ruta diferente.",
"sessions.sidebar.activity.recentTitle": "reciente",
"sessions.sidebar.activity.chatsTitle": "chats",
"chat.chatInput.chooseProject": "Elegir proyecto",
"sessions.archivePage.allDirectories": "Todos los directorios",
"sessions.sidebar.header.displayMode.stickyHeaders": "Encabezados de proyecto fijos",
"sessions.sidebar.header.grouping.label": "Agrupar sesiones",
+2
View File
@@ -268,6 +268,8 @@ export const dict = {
'sessions.sidebar.empty.noMatches.title': 'Aucune session correspondante',
'sessions.sidebar.empty.noMatches.description': 'Essayez un autre titre, branche, dossier ou chemin.',
'sessions.sidebar.activity.recentTitle': 'récent',
'sessions.sidebar.activity.chatsTitle': 'discussions',
'chat.chatInput.chooseProject': 'Choisir un projet',
'sessions.archivePage.allDirectories': 'Tous les répertoires',
'sessions.sidebar.header.displayMode.stickyHeaders': 'Épingler les en-têtes de projet',
'sessions.sidebar.header.grouping.label': 'Regrouper les sessions',
+2
View File
@@ -438,6 +438,8 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.empty.noMatches.title': '一致するセッションがありません',
'sessions.sidebar.empty.noMatches.description': '別のタイトル、ブランチ、フォルダ、パスをお試しください。',
'sessions.sidebar.activity.recentTitle': '最近',
'sessions.sidebar.activity.chatsTitle': 'チャット',
'chat.chatInput.chooseProject': 'プロジェクトを選択',
'sessions.archivePage.allDirectories': 'すべてのディレクトリ',
'sessions.sidebar.header.displayMode.stickyHeaders': 'プロジェクトヘッダーを固定',
'sessions.sidebar.header.grouping.label': 'セッションのグループ化',
+2
View File
@@ -438,6 +438,8 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.empty.noMatches.title': '일치하는 세션 없음',
'sessions.sidebar.empty.noMatches.description': '다른 제목, 브랜치, 폴더 또는 경로로 검색해 보세요.',
'sessions.sidebar.activity.recentTitle': '최근',
'sessions.sidebar.activity.chatsTitle': '채팅',
'chat.chatInput.chooseProject': '프로젝트 선택',
'sessions.archivePage.allDirectories': '모든 디렉터리',
'sessions.sidebar.header.displayMode.stickyHeaders': '프로젝트 헤더 고정',
'sessions.sidebar.header.grouping.label': '세션 그룹화',
+2
View File
@@ -249,6 +249,8 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.empty.noMatches.title': 'Brak pasujących sesji',
'sessions.sidebar.empty.noMatches.description': 'Spróbuj innego tytułu, gałęzi, folderu lub ścieżki.',
'sessions.sidebar.activity.recentTitle': 'ostatnie',
'sessions.sidebar.activity.chatsTitle': 'czaty',
'chat.chatInput.chooseProject': 'Wybierz projekt',
'sessions.archivePage.allDirectories': 'Wszystkie katalogi',
'sessions.sidebar.header.displayMode.stickyHeaders': 'Przyklejone nagłówki projektów',
'sessions.sidebar.header.grouping.label': 'Grupowanie sesji',
@@ -438,6 +438,8 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.empty.noMatches.title": "Não há sessões coincidentes",
"sessions.sidebar.empty.noMatches.description": "Tente com outro título, branch, pasta ou caminho.",
"sessions.sidebar.activity.recentTitle": "recente",
"sessions.sidebar.activity.chatsTitle": "conversas",
"chat.chatInput.chooseProject": "Escolher projeto",
"sessions.archivePage.allDirectories": "Todos os diretórios",
"sessions.sidebar.header.displayMode.stickyHeaders": "Cabeçalhos de projeto fixos",
"sessions.sidebar.header.grouping.label": "Agrupar sessões",
+2
View File
@@ -438,6 +438,8 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.empty.noMatches.title": "Немає відповідних сесій",
"sessions.sidebar.empty.noMatches.description": "Спробуйте інший заголовок, гілку, папку або шлях.",
"sessions.sidebar.activity.recentTitle": "Останні",
"sessions.sidebar.activity.chatsTitle": "Чати",
"chat.chatInput.chooseProject": "Вибрати проєкт",
"sessions.archivePage.allDirectories": "Всі директорії",
"sessions.sidebar.header.displayMode.stickyHeaders": "Липкі заголовки проектів",
"sessions.sidebar.header.grouping.label": "Групування сесій",
@@ -438,6 +438,8 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.empty.noMatches.title': '没有匹配的会话',
'sessions.sidebar.empty.noMatches.description': '请尝试其他标题、分支、文件夹或路径。',
'sessions.sidebar.activity.recentTitle': '最近',
'sessions.sidebar.activity.chatsTitle': '聊天',
'chat.chatInput.chooseProject': '选择项目',
'sessions.archivePage.allDirectories': '所有目录',
'sessions.sidebar.header.displayMode.stickyHeaders': '固定项目标题',
'sessions.sidebar.header.grouping.label': '会话分组',
@@ -451,6 +451,8 @@ export const dict: Record<I18nKey, string> = {
'sessions.sidebar.empty.noMatches.title': '沒有符合的會話',
'sessions.sidebar.empty.noMatches.description': '請嘗試其他標題、分支、資料夾或路徑。',
'sessions.sidebar.activity.recentTitle': '最近',
'sessions.sidebar.activity.chatsTitle': '聊天',
'chat.chatInput.chooseProject': '選擇專案',
'sessions.archivePage.allDirectories': '所有目錄',
'sessions.sidebar.header.displayMode.stickyHeaders': '固定專案標題',
'sessions.sidebar.header.grouping.label': '工作階段分組',