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:
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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})`);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '세션 그룹화',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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': '工作階段分組',
|
||||
|
||||
Reference in New Issue
Block a user