+ {section.key === 'chats' && props.onNewChat ? (
+
+
+
+
+
+
+ {t('sessions.sidebar.header.actions.newSession')}
+
+
+
+ ) : null}
{!isCollapsed ? (
diff --git a/packages/ui/src/components/session/sidebar/hooks/useSwitcherItems.ts b/packages/ui/src/components/session/sidebar/hooks/useSwitcherItems.ts
index 1329daab..42e3e05b 100644
--- a/packages/ui/src/components/session/sidebar/hooks/useSwitcherItems.ts
+++ b/packages/ui/src/components/session/sidebar/hooks/useSwitcherItems.ts
@@ -9,6 +9,8 @@ import type { SessionNode } from '../types';
import { isPathWithinProject } from '../utils';
import { compareSessionsByLifecycleOrder, useSessionOrderingStore } from '@/sync/session-ordering';
import { useSessionUIStore } from '@/sync/session-ui-store';
+import { isVSCodeRuntime } from '@/lib/desktop';
+import { isChatDirectoryPath } from '@/lib/chatDirectories';
export type SwitcherItem = {
node: SessionNode;
@@ -51,6 +53,7 @@ export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions
const sessionOrderRanks = useSessionOrderingStore((state) => state.rankById);
const branchesByDirectory = useGitAllBranches();
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
+ const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
// Worktree sessions live OUTSIDE their project's path, so prefix matching
// can't resolve their project — and their branch is known from worktree
@@ -114,6 +117,7 @@ export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions
const parents = activeSessions
.filter((session) => !session.time?.archived)
+ .filter((session) => !isVSCode || !isChatDirectoryPath(resolveGlobalSessionDirectory(session)))
.filter((session) => !(session as Session & { parentID?: string | null }).parentID)
.filter((session) => {
if (!scopeProjectId) return true;
@@ -151,7 +155,7 @@ export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions
},
};
});
- }, [activeSessions, branchesByDirectory, enabled, findProjectForDirectory, maxParents, pinnedSessionIds, scopeProjectId, sessionOrderRanks, worktreeInfoByPath]);
+ }, [activeSessions, branchesByDirectory, enabled, findProjectForDirectory, isVSCode, maxParents, pinnedSessionIds, scopeProjectId, sessionOrderRanks, worktreeInfoByPath]);
return items;
};
diff --git a/packages/ui/src/components/session/sidebar/sidebarSessionSources.test.ts b/packages/ui/src/components/session/sidebar/sidebarSessionSources.test.ts
new file mode 100644
index 00000000..e19213f7
--- /dev/null
+++ b/packages/ui/src/components/session/sidebar/sidebarSessionSources.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, test } from 'bun:test';
+import type { Session } from '@opencode-ai/sdk/v2';
+
+import { mergeSidebarSessionSources } from './sidebarSessionSources';
+
+const session = (id: string, title: string): Session => ({
+ id,
+ slug: id,
+ title,
+ directory: `/home/.config/openchamber/chats/2026-08-21/${id}`,
+ projectID: 'managed-chats',
+ version: '1',
+ time: { created: 1, updated: 1 },
+});
+
+describe('sidebar session source merge', () => {
+ test('shows one row when the same cached global chat also exists live', () => {
+ const live = session('session-a', 'Live title');
+ const cached = session('session-a', 'Cached title');
+
+ expect(mergeSidebarSessionSources([cached], [live])).toEqual([cached]);
+ });
+
+ test('prefers global authority over live fallback', () => {
+ const global = session('session-a', 'Global title');
+ expect(mergeSidebarSessionSources([global], [session('session-a', 'Live title')])).toEqual([global]);
+ });
+});
diff --git a/packages/ui/src/components/session/sidebar/sidebarSessionSources.ts b/packages/ui/src/components/session/sidebar/sidebarSessionSources.ts
new file mode 100644
index 00000000..41e22d04
--- /dev/null
+++ b/packages/ui/src/components/session/sidebar/sidebarSessionSources.ts
@@ -0,0 +1,19 @@
+import type { Session } from '@opencode-ai/sdk/v2';
+
+export function mergeSidebarSessionSources(
+ globalSessions: readonly Session[],
+ liveSessions: readonly Session[],
+): Session[] {
+ const merged = [...globalSessions];
+ const seenIds = new Set(merged.map((session) => session.id));
+ const appendMissing = (sessions: readonly Session[]) => {
+ sessions.forEach((session) => {
+ if (seenIds.has(session.id)) return;
+ seenIds.add(session.id);
+ merged.push(session);
+ });
+ };
+
+ appendMissing(liveSessions);
+ return merged;
+}
diff --git a/packages/ui/src/hooks/useMiniChatKeyboardShortcuts.ts b/packages/ui/src/hooks/useMiniChatKeyboardShortcuts.ts
index f3b24003..b7f9eeb8 100644
--- a/packages/ui/src/hooks/useMiniChatKeyboardShortcuts.ts
+++ b/packages/ui/src/hooks/useMiniChatKeyboardShortcuts.ts
@@ -3,16 +3,12 @@ import { focusChatInput } from '@/components/chat/composer/editor/dom';
import { canUseElectronDesktopIPC, invokeDesktop } from '@/lib/desktop';
import { eventMatchesShortcut, getEffectiveShortcutCombo } from '@/lib/shortcuts';
import { useConfigStore } from '@/stores/useConfigStore';
-import { useDirectoryStore } from '@/stores/useDirectoryStore';
-import { useProjectsStore } from '@/stores/useProjectsStore';
import { useUIStore } from '@/stores/useUIStore';
import { useSelectionStore } from '@/sync/selection-store';
import { useSessionUIStore } from '@/sync/session-ui-store';
export const useMiniChatKeyboardShortcuts = () => {
const shortcutOverrides = useUIStore((state) => state.shortcutOverrides);
- const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
- const activeProject = useProjectsStore((state) => state.getActiveProject());
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
React.useEffect(() => {
@@ -28,8 +24,8 @@ export const useMiniChatKeyboardShortcuts = () => {
if (canUseElectronDesktopIPC() && eventMatchesShortcut(event, combo('new_mini_chat'))) {
event.preventDefault();
void invokeDesktop('desktop_open_draft_mini_chat_window', {
- directory: currentDirectory || activeProject?.path || '',
- projectId: activeProject?.id ?? null,
+ directory: '',
+ projectId: null,
})?.catch((error) => {
console.warn('[mini-chat-shortcuts] failed to open draft mini chat window', error);
});
@@ -38,11 +34,7 @@ export const useMiniChatKeyboardShortcuts = () => {
if (eventMatchesShortcut(event, combo('new_chat'))) {
event.preventDefault();
- openNewSessionDraft({
- selectedProjectId: activeProject?.id ?? null,
- directoryOverride: currentDirectory || activeProject?.path || null,
- preserveDirectoryOverride: Boolean(currentDirectory || activeProject?.path),
- });
+ openNewSessionDraft();
focusChatInput();
return;
}
@@ -98,5 +90,5 @@ export const useMiniChatKeyboardShortcuts = () => {
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
- }, [activeProject?.id, activeProject?.path, currentDirectory, openNewSessionDraft, shortcutOverrides]);
+ }, [openNewSessionDraft, shortcutOverrides]);
};
diff --git a/packages/ui/src/lib/chatDirectories.test.ts b/packages/ui/src/lib/chatDirectories.test.ts
new file mode 100644
index 00000000..6d6958b4
--- /dev/null
+++ b/packages/ui/src/lib/chatDirectories.test.ts
@@ -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']);
+ });
+});
diff --git a/packages/ui/src/lib/chatDirectories.ts b/packages/ui/src/lib/chatDirectories.ts
new file mode 100644
index 00000000..c7656dee
--- /dev/null
+++ b/packages/ui/src/lib/chatDirectories.ts
@@ -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>();
+
+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 {
+ 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 {
+ 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 {
+ 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 {
+ 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})`);
+ }
+}
diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts
index fec60411..b40072d7 100644
--- a/packages/ui/src/lib/i18n/messages/de.ts
+++ b/packages/ui/src/lib/i18n/messages/de.ts
@@ -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',
diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts
index 4abb4e1d..a2b229fc 100644
--- a/packages/ui/src/lib/i18n/messages/en.ts
+++ b/packages/ui/src/lib/i18n/messages/en.ts
@@ -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',
diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts
index 458a6adc..36c30760 100644
--- a/packages/ui/src/lib/i18n/messages/es.ts
+++ b/packages/ui/src/lib/i18n/messages/es.ts
@@ -438,6 +438,8 @@ export const dict: Record = {
"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",
diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts
index bba07ce2..40e496bf 100644
--- a/packages/ui/src/lib/i18n/messages/fr.ts
+++ b/packages/ui/src/lib/i18n/messages/fr.ts
@@ -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',
diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts
index 7d26028d..980973e3 100644
--- a/packages/ui/src/lib/i18n/messages/ja.ts
+++ b/packages/ui/src/lib/i18n/messages/ja.ts
@@ -438,6 +438,8 @@ export const dict: Record = {
'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': 'セッションのグループ化',
diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts
index b7a0accd..6b409fdf 100644
--- a/packages/ui/src/lib/i18n/messages/ko.ts
+++ b/packages/ui/src/lib/i18n/messages/ko.ts
@@ -438,6 +438,8 @@ export const dict: Record = {
'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': '세션 그룹화',
diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts
index d9247510..33ba4418 100644
--- a/packages/ui/src/lib/i18n/messages/pl.ts
+++ b/packages/ui/src/lib/i18n/messages/pl.ts
@@ -249,6 +249,8 @@ export const dict: Record = {
'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',
diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts
index b5dd9ae7..48904a98 100644
--- a/packages/ui/src/lib/i18n/messages/pt-BR.ts
+++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts
@@ -438,6 +438,8 @@ export const dict: Record = {
"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",
diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts
index e30dea88..110ceb18 100644
--- a/packages/ui/src/lib/i18n/messages/uk.ts
+++ b/packages/ui/src/lib/i18n/messages/uk.ts
@@ -438,6 +438,8 @@ export const dict: Record = {
"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": "Групування сесій",
diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts
index 0b1034e3..fdfc55a7 100644
--- a/packages/ui/src/lib/i18n/messages/zh-CN.ts
+++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts
@@ -438,6 +438,8 @@ export const dict: Record = {
'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': '会话分组',
diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts
index 4b2caab8..aad95ad1 100644
--- a/packages/ui/src/lib/i18n/messages/zh-TW.ts
+++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts
@@ -451,6 +451,8 @@ export const dict: Record = {
'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': '工作階段分組',
diff --git a/packages/ui/src/stores/globalSessions.test.ts b/packages/ui/src/stores/globalSessions.test.ts
index 5001bd40..10421d0c 100644
--- a/packages/ui/src/stores/globalSessions.test.ts
+++ b/packages/ui/src/stores/globalSessions.test.ts
@@ -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 () => {
diff --git a/packages/ui/src/stores/globalSessions.ts b/packages/ui/src/stores/globalSessions.ts
index da8ed9ea..5ee695b9 100644
--- a/packages/ui/src/stores/globalSessions.ts
+++ b/packages/ui/src/stores/globalSessions.ts
@@ -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;
diff --git a/packages/ui/src/stores/useGlobalSessionsStore.ts b/packages/ui/src/stores/useGlobalSessionsStore.ts
index 7f6f3854..68919369 100644
--- a/packages/ui/src/stores/useGlobalSessionsStore.ts
+++ b/packages/ui/src/stores/useGlobalSessionsStore.ts
@@ -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 => {
+ 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
};
const applySessionUpserts = (state: GlobalSessionsState, sessions: Session[]): Partial => {
+ 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((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((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((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 => {
const state = useGlobalSessionsStore.getState();
if (state.hasLoaded && state.status !== 'error') {
diff --git a/packages/ui/src/sync/DOCUMENTATION.md b/packages/ui/src/sync/DOCUMENTATION.md
index e883d128..c88021a3 100644
--- a/packages/ui/src/sync/DOCUMENTATION.md
+++ b/packages/ui/src/sync/DOCUMENTATION.md
@@ -324,6 +324,16 @@ metadata and the next authoritative load reconciles it.
## The golden rule
+### Managed chat directories
+
+Ordinary user-created drafts default to the OpenChamber-managed Chat target. The first submit creates one isolated directory under `~/.config/openchamber/chats/YYYY-MM-DD/session-` before creating the OpenCode session. The shared `~/.config/openchamber/chats` root acts as a system project owner for sidebar membership and Notes, Todo, Plans, pinned knowledge, and project memory, but it is never persisted or rendered as a user project and exposes no Git/worktree controls. Project and worktree actions remain explicit targets. Archiving retains a chat directory so restore remains lossless. Confirmed deletion removes that managed directory and never removes project directories.
+
+Typing the first character in a managed Chat draft starts one deduplicated directory preparation for that draft. Materialization consumes the prepared directory before `createSession`, removing filesystem creation from the usual submit path. Closing the draft, changing it to a project target, or completing preparation after the runtime/draft changed deletes the unclaimed directory. A create failure also deletes the consumed directory.
+
+The global sessions store persists and hydrates one bounded, runtime-scoped startup snapshot containing only active managed chat sessions. Every global session surface, including the main sidebar and Electron Mini Chat switcher, sees that stale snapshot while the global list is unresolved or failed; the first authoritative global snapshot replaces it. Runtime reset to idle must hydrate rather than erase the destination runtime's snapshot; authoritative empty, archive, and delete updates do persist the resulting empty or reduced list.
+
+VS Code intentionally has no managed Chats mode. It neither reads nor writes the managed Chats startup cache, regular drafts continue to target the open workspace, and the global session store rejects managed chat sessions from both snapshots and live upserts before any VS Code surface can consume them. Sidebar and switcher filters repeat that exclusion defensively.
+
When creating a draft in `handleDirectoryEvent`, **only clone the state fields the event will mutate**. Never spread all fields eagerly.
```typescript
diff --git a/packages/ui/src/sync/__tests__/issue-2039.test.ts b/packages/ui/src/sync/__tests__/issue-2039.test.ts
index 5768c3b2..70a3048e 100644
--- a/packages/ui/src/sync/__tests__/issue-2039.test.ts
+++ b/packages/ui/src/sync/__tests__/issue-2039.test.ts
@@ -73,6 +73,8 @@ mock.module("@/stores/utils/safeStorage", () => ({
mock.module("@/lib/opencode/client", () => ({
opencodeClient: {
getDirectory: () => null,
+ getFilesystemHome: mock(async () => "/home/test"),
+ createDirectory: mock(async (path: string) => ({ success: true, path })),
setDirectory: mock(() => undefined),
},
}))
@@ -327,9 +329,11 @@ describe("issue 2039 draft auto-accept", () => {
currentSessionId: null,
currentSessionDirectory: null,
newSessionDraft: {
+ draftId: 0,
open: false,
directoryOverride: null,
parentID: null,
+ target: "chat",
},
})
})
diff --git a/packages/ui/src/sync/persist-cache.test.ts b/packages/ui/src/sync/persist-cache.test.ts
index 7830f5f5..2afc3889 100644
--- a/packages/ui/src/sync/persist-cache.test.ts
+++ b/packages/ui/src/sync/persist-cache.test.ts
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import type { Session } from "@opencode-ai/sdk/v2/client"
import { switchRuntimeEndpoint } from "@/lib/runtime-switch"
-import { persistSessions, readDirCache } from "./persist-cache"
+import { persistManagedChatSessions, persistSessions, readDirCache, readManagedChatSessions } from "./persist-cache"
import { getSyncPerformanceDiagnostics, setSyncPerformanceDiagnosticsEnabled } from "./performance-diagnostics"
class TestStorage implements Storage {
@@ -81,6 +81,17 @@ afterEach(() => {
})
describe("persisted directory sessions", () => {
+ test("keeps one runtime-scoped startup snapshot for managed chats", async () => {
+ const chat = session(1, 2, "Chat", "/home/user/.config/openchamber/chats/2026-08-21/session-a")
+ persistManagedChatSessions([session(2, 3), chat])
+ await waitForPersistence()
+
+ expect(readManagedChatSessions().map((item) => item.id)).toEqual([chat.id])
+
+ switchRuntimeEndpoint({ apiBaseUrl: "https://runtime-other.test", runtimeKey: "runtime-other" })
+ expect(readManagedChatSessions()).toEqual([])
+ })
+
test("keeps the 50 most recently updated sessions across restart reads", async () => {
const sessions = Array.from({ length: 60 }, (_, updated) => session(59 - updated, updated))
diff --git a/packages/ui/src/sync/persist-cache.ts b/packages/ui/src/sync/persist-cache.ts
index a6fe49c0..51d3e580 100644
--- a/packages/ui/src/sync/persist-cache.ts
+++ b/packages/ui/src/sync/persist-cache.ts
@@ -10,11 +10,14 @@ import type { Session, VcsInfo } from "@opencode-ai/sdk/v2/client"
import type { ProjectMeta } from "./types"
import { getRuntimeKey, subscribeRuntimeEndpointWillChange } from "@/lib/runtime-switch"
import { countSyncPersistenceSerialization, countSyncPersistenceStorageWrite } from "./performance-diagnostics"
+import { isChatDirectoryPath } from "@/lib/chatDirectories"
+import { isVSCodeRuntime } from "@/lib/desktop"
/** Cap persisted session lists so localStorage stays bounded per directory. */
const PERSISTED_SESSION_LIMIT = 50
const SESSION_CACHE_FALLBACK_LIMITS = [PERSISTED_SESSION_LIMIT, 25, 10, 5, 1] as const
const SESSION_PERSIST_DEBOUNCE_MS = 50
+const MANAGED_CHATS_CACHE_SCOPE = "openchamber:managed-chats"
type PendingSessionWrite = {
runtimeKey: string
@@ -241,6 +244,21 @@ export function persistSessions(directory: string, sessions: Session[] | undefin
scheduleSessionCacheWrite(directory, sessions)
}
+export function readManagedChatSessions(expectedRuntimeKey = getRuntimeKey()): Session[] {
+ if (isVSCodeRuntime()) return []
+ if (expectedRuntimeKey !== getRuntimeKey()) return []
+ return readDirCache(MANAGED_CHATS_CACHE_SCOPE).sessions?.filter((session) => (
+ isChatDirectoryPath(session.directory)
+ )) ?? []
+}
+
+export function persistManagedChatSessions(sessions: Session[]): void {
+ if (isVSCodeRuntime()) return
+ persistSessions(MANAGED_CHATS_CACHE_SCOPE, sessions.filter((session) => (
+ isChatDirectoryPath(session.directory)
+ )))
+}
+
/** Write vcs info to cache */
export function persistVcs(directory: string, vcs: VcsInfo | undefined): void {
writeCache(directory, "vcs", vcs)
diff --git a/packages/ui/src/sync/session-actions.test.ts b/packages/ui/src/sync/session-actions.test.ts
index 4870e39f..758d99ed 100644
--- a/packages/ui/src/sync/session-actions.test.ts
+++ b/packages/ui/src/sync/session-actions.test.ts
@@ -125,6 +125,7 @@ mock.module("@/lib/opencode/client", () => ({
return mockScopedClient
},
getDirectory: () => "/test/project",
+ getFilesystemHome: mock(async () => "/home/test"),
getSdkClient: () => mockSdk,
replyToPermission: mock((requestId: string, reply: string, options?: { directory?: string | null }) => {
replyCalls.push({ method: "permission.reply", params: { requestID: requestId, reply, directory: options?.directory } })
diff --git a/packages/ui/src/sync/session-actions.ts b/packages/ui/src/sync/session-actions.ts
index 806cc913..7ab6e370 100644
--- a/packages/ui/src/sync/session-actions.ts
+++ b/packages/ui/src/sync/session-actions.ts
@@ -35,6 +35,7 @@ import { getStaleRunningToolMessageID } from "./materialization"
import { normalizePath } from "@/lib/pathNormalization"
import { mergeMessages } from "./optimistic"
import { messagesBefore, messagesFrom } from "./message-ordering"
+import { deleteChatDirectory } from "@/lib/chatDirectories"
const MESSAGE_REFETCH_LIMIT = 100
const SEND_CONFIRMATION_REFETCH_LIMIT = 30
@@ -919,6 +920,15 @@ function finalizeConfirmedSessionDeletion(
}
}
+async function cleanupDeletedChatDirectory(directory: string | undefined, deleteDirectory: boolean): Promise {
+ if (!directory || !deleteDirectory) return
+ try {
+ await deleteChatDirectory(directory)
+ } catch (error) {
+ console.warn("[session-actions] deleted chat directory cleanup failed", error)
+ }
+}
+
export type DeleteSessionOptions = {
/**
* Runtime key the deletion is scoped to. Defaults to the active runtime when
@@ -947,6 +957,8 @@ export async function deleteSession(sessionId: string, options?: DeleteSessionOp
const expectedRuntimeKey = options?.expectedRuntimeKey ?? getRuntimeKey()
if (isStaleRuntime(expectedRuntimeKey)) return false
const sessionDirectory = getSessionDirectory(sessionId)
+ const sessionSnapshot = getGlobalSessionSnapshot(sessionId)
+ const deleteManagedDirectory = Boolean(sessionSnapshot && sessionSnapshot.parentID == null)
try {
await cleanupReviewMetadataBeforeDelete(sessionId, sessionDirectory, expectedRuntimeKey)
if (isStaleRuntime(expectedRuntimeKey)) return false
@@ -956,6 +968,7 @@ export async function deleteSession(sessionId: string, options?: DeleteSessionOp
throw new Error("session.delete failed: server did not confirm deletion")
}
finalizeConfirmedSessionDeletion(sessionId, sessionDirectory, expectedRuntimeKey)
+ await cleanupDeletedChatDirectory(sessionDirectory, deleteManagedDirectory)
return true
} catch (error) {
console.error("[session-actions] deleteSession failed", error)
@@ -965,6 +978,7 @@ export async function deleteSession(sessionId: string, options?: DeleteSessionOp
if ((error as { status?: number })?.status === 404) {
if (isStaleRuntime(expectedRuntimeKey)) return false
finalizeConfirmedSessionDeletion(sessionId, sessionDirectory, expectedRuntimeKey)
+ await cleanupDeletedChatDirectory(sessionDirectory, deleteManagedDirectory)
return true
}
return false
@@ -978,6 +992,8 @@ export async function deleteSessionInDirectory(
expectedRuntimeKey = getRuntimeKey(),
): Promise {
if (isStaleRuntime(expectedRuntimeKey)) return false
+ const sessionSnapshot = getGlobalSessionSnapshot(sessionId)
+ const deleteManagedDirectory = Boolean(sessionSnapshot && sessionSnapshot.parentID == null)
try {
await cleanupReviewMetadataBeforeDelete(sessionId, directory, expectedRuntimeKey)
if (isStaleRuntime(expectedRuntimeKey)) return false
@@ -987,12 +1003,14 @@ export async function deleteSessionInDirectory(
throw new Error("session.delete failed: server did not confirm deletion")
}
finalizeConfirmedSessionDeletion(sessionId, directory, expectedRuntimeKey)
+ await cleanupDeletedChatDirectory(directory, deleteManagedDirectory)
return true
} catch (error) {
console.error("[session-actions] deleteSessionInDirectory failed", error)
if ((error as { status?: number })?.status === 404) {
if (isStaleRuntime(expectedRuntimeKey)) return false
finalizeConfirmedSessionDeletion(sessionId, directory, expectedRuntimeKey)
+ await cleanupDeletedChatDirectory(directory, deleteManagedDirectory)
return true
}
return false
diff --git a/packages/ui/src/sync/session-ui-store.test.js b/packages/ui/src/sync/session-ui-store.test.js
index 07a8a19f..6f2fdc5e 100644
--- a/packages/ui/src/sync/session-ui-store.test.js
+++ b/packages/ui/src/sync/session-ui-store.test.js
@@ -370,16 +370,17 @@ describe('openNewSessionDraft project binding', () => {
useDirectoryStore.getState().setDirectory(projectB.path, { showOverlay: false });
});
- test('keeps implicit draft on current directory when active project differs', () => {
+ test('defaults an implicit draft to Chat when active project differs', () => {
useSessionUIStore.getState().openNewSessionDraft();
const draft = useSessionUIStore.getState().newSessionDraft;
expect(draft.open).toBe(true);
- expect(draft.selectedProjectId).toBe(projectB.id);
- expect(draft.directoryOverride).toBe(projectB.path);
+ expect(draft.target).toBe('chat');
+ expect(draft.selectedProjectId).toBeNull();
+ expect(draft.directoryOverride).toBeNull();
});
- test('does not attach active project when current directory is unmatched', () => {
+ test('defaults an implicit draft to Chat when current directory is unmatched', () => {
useDirectoryStore.getState().setDirectory('/external/worktree', { showOverlay: false });
useSessionUIStore.getState().openNewSessionDraft();
@@ -387,7 +388,8 @@ describe('openNewSessionDraft project binding', () => {
expect(draft.open).toBe(true);
expect(draft.selectedProjectId).toBeNull();
- expect(draft.directoryOverride).toBe('/external/worktree');
+ expect(draft.target).toBe('chat');
+ expect(draft.directoryOverride).toBeNull();
});
test('respects explicit directoryOverride over active project', () => {
@@ -464,7 +466,7 @@ describe('createSession draft lifecycle', () => {
useDirectoryStore.getState().setDirectory('/private/deleted-worktree', { showOverlay: false });
opencodeClient.getDirectoryAvailability = async () => 'missing';
- useSessionUIStore.getState().openNewSessionDraft();
+ useSessionUIStore.getState().openNewSessionDraft({ directoryOverride: '/private/deleted-worktree' });
await Bun.sleep(0);
expect(useSessionUIStore.getState().newSessionDraft.directoryOverride).toBe('/projects/main');
@@ -482,7 +484,7 @@ describe('createSession draft lifecycle', () => {
activeProjectId: 'project-active',
});
useDirectoryStore.getState().setDirectory('/private/deleted-worktree', { showOverlay: false });
- useSessionUIStore.getState().openNewSessionDraft();
+ useSessionUIStore.getState().openNewSessionDraft({ directoryOverride: '/private/deleted-worktree' });
opencodeClient.getDirectoryAvailability = async () => 'missing';
opencodeClient.createSession = async (_params, directory) => {
createSessionCalls.push(directory);
@@ -542,7 +544,7 @@ describe('createSession draft lifecycle', () => {
activeProjectId: 'project-main',
});
useDirectoryStore.getState().setDirectory('/private/unavailable-worktree', { showOverlay: false });
- useSessionUIStore.getState().openNewSessionDraft();
+ useSessionUIStore.getState().openNewSessionDraft({ directoryOverride: '/private/unavailable-worktree' });
opencodeClient.getDirectoryAvailability = async () => 'unknown';
opencodeClient.createSession = async (_params, directory) => {
createSessionCalls.push(directory);
@@ -571,7 +573,7 @@ describe('createSession draft lifecycle', () => {
return { id: 'session-race', directory };
};
- useSessionUIStore.getState().openNewSessionDraft();
+ useSessionUIStore.getState().openNewSessionDraft({ directoryOverride: '/private/deleted-worktree' });
const createPromise = useSessionUIStore.getState().createSession('Draft title', '/private/deleted-worktree');
expect(availabilityResolvers.length).toBe(2);
diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts
index 7793de6a..ff337e17 100644
--- a/packages/ui/src/sync/session-ui-store.ts
+++ b/packages/ui/src/sync/session-ui-store.ts
@@ -29,6 +29,8 @@ import { useSkillsStore } from "@/stores/useSkillsStore"
import { getDeferredSafeStorage } from "@/stores/utils/safeStorage"
import { markPendingUserSendAnimation } from "@/lib/userSendAnimation"
import { normalizePath } from "@/lib/pathNormalization"
+import { CHAT_DRAFT_PROJECT_ID, createChatDirectory, deleteChatDirectory, warmChatsRootDirectory } from "@/lib/chatDirectories"
+import { isVSCodeRuntime } from "@/lib/desktop"
import { flattenAssistantTextParts } from "@/lib/messages/messageText"
import { composeForkSessionMessage } from "@/lib/messages/executionMeta"
import { findLatestUserModelChoice } from "@/lib/messages/userModelChoice"
@@ -258,6 +260,7 @@ function notifyMessageSent(sessionId: string): void {
// ---------------------------------------------------------------------------
export type NewSessionDraftState = {
+ draftId: number
open: boolean
selectedProjectId?: string | null
directoryOverride: string | null
@@ -271,6 +274,8 @@ export type NewSessionDraftState = {
syntheticParts?: SyntheticContextPart[]
targetFolderId?: string
projectContextPins?: { notes: string[]; plans: string[] }
+ target: "chat" | "project"
+ preparedChatDirectory?: string | null
}
export type ViewportAnchor = {
@@ -316,6 +321,7 @@ export type SessionUIState = {
prepareForRuntimeSwitch: (apiBaseUrl?: string | null) => void
restoreForRuntimeSwitch: (apiBaseUrl?: string | null) => void
openNewSessionDraft: (options?: Partial & { automatic?: boolean }) => void
+ prepareChatDraftDirectory: () => Promise
closeNewSessionDraft: () => void
setNewSessionDraftTarget: (target: { projectId?: string | null; selectedProjectId?: string | null; directoryOverride?: string | null }, options?: { force?: boolean }) => void
setDraftPreserveDirectoryOverride: (value: boolean) => void
@@ -548,10 +554,14 @@ const activateConfigForDirectory = async (directory: string | null | undefined):
}
const DEFAULT_DRAFT: NewSessionDraftState = {
+ draftId: 0,
open: false,
directoryOverride: null,
parentID: null,
+ target: "chat",
}
+let nextDraftId = 1
+const pendingChatDirectoryByDraft = new Map>()
const activeSessionByRuntime = new Map()
type RuntimeSessionMemory = {
@@ -726,6 +736,18 @@ export async function materializeOpenDraftSession(selection: {
store.resolvePendingDraftWorktreeTarget(draft.pendingWorktreeRequestId, draftDirectoryOverride)
}
+ const isChatDraft = draft.target === "chat"
+ if (isChatDraft) {
+ draftDirectoryOverride = await store.prepareChatDraftDirectory()
+ if (!draftDirectoryOverride) throw new Error("Failed to prepare chat directory")
+ const currentDraft = useSessionUIStore.getState().newSessionDraft
+ if (currentDraft.draftId === draft.draftId) {
+ useSessionUIStore.setState({
+ newSessionDraft: { ...currentDraft, preparedChatDirectory: null },
+ })
+ }
+ }
+
await waitForWorktreeBootstrapIfConfigured(draftDirectoryOverride, draftProjectId)
const draftPins = draft.projectContextPins ?? { notes: [], plans: [] }
@@ -737,7 +759,12 @@ export async function materializeOpenDraftSession(selection: {
? { openchamber: { project_context_pins: draftPins } }
: undefined,
)
- if (!created?.id) throw new Error("Failed to create session")
+ if (!created?.id) {
+ if (isChatDraft && draftDirectoryOverride) {
+ await deleteChatDirectory(draftDirectoryOverride).catch(() => undefined)
+ }
+ throw new Error("Failed to create session")
+ }
// The server response is authoritative. It may canonicalize a requested
// worktree path (for example through a symlink or platform path casing).
@@ -989,7 +1016,16 @@ export const useSessionUIStore = create()((set, get) => ({
const explicitDirectory = options?.directoryOverride !== undefined
? normalizePath(options.directoryOverride)
: null
- const explicitProject = options?.selectedProjectId
+ let target = isVSCodeRuntime() ? "project" : options?.target
+ if (!target) {
+ const hasExplicitProjectTarget = options?.directoryOverride !== undefined
+ || (options?.selectedProjectId !== undefined && options.selectedProjectId !== CHAT_DRAFT_PROJECT_ID)
+ || isVSCodeRuntime()
+ target = options?.selectedProjectId === CHAT_DRAFT_PROJECT_ID || !hasExplicitProjectTarget
+ ? "chat"
+ : "project"
+ }
+ const explicitProject = target === "project" && options?.selectedProjectId
? projects.find((p) => p.id === options.selectedProjectId) ?? null
: null
@@ -1006,14 +1042,14 @@ export const useSessionUIStore = create()((set, get) => ({
const persistedProjectByDir = resolveDraftProjectForDirectory(projects, availableWorktreesByProject, persistedTarget?.directory ?? null)
const currentDirProject = resolveDraftProjectForDirectory(projects, availableWorktreesByProject, currentDirectory)
- const selectedProject = (() => {
+ const selectedProject = target === "chat" ? null : (() => {
if (explicitProject) return explicitProject
if (explicitDirectory !== null) return inferredProjectFromDir
if (currentDirectory) return currentDirProject
return persistedProjectByDir ?? persistedProjectById ?? fallbackProject
})()
- const directory = (() => {
+ const directory = target === "chat" ? null : (() => {
if (explicitDirectory !== null) return explicitDirectory
if (explicitProject) return normalizePath(explicitProject.path ?? null)
if (currentDirectory) return currentDirectory
@@ -1021,10 +1057,17 @@ export const useSessionUIStore = create()((set, get) => ({
return normalizePath(selectedProject?.path ?? null)
})()
+ if (target === "chat") {
+ warmChatsRootDirectory()
+ }
+
persistDraftTarget({ projectId: selectedProject?.id ?? null, directory })
const nextDraft: NewSessionDraftState = {
+ draftId: nextDraftId++,
open: true,
+ target,
+ preparedChatDirectory: null,
selectedProjectId: selectedProject?.id ?? null,
directoryOverride: directory,
permissionAutoAcceptEnabled: options?.permissionAutoAcceptEnabled === true,
@@ -1040,9 +1083,7 @@ export const useSessionUIStore = create()((set, get) => ({
}
set({
- newSessionDraft: {
- ...nextDraft,
- },
+ newSessionDraft: nextDraft,
currentSessionId: null,
currentSessionDirectory: null,
error: null,
@@ -1078,11 +1119,44 @@ export const useSessionUIStore = create()((set, get) => ({
void recoverStaleDraftDirectory(nextDraft)
},
+ prepareChatDraftDirectory: async () => {
+ const draft = get().newSessionDraft
+ if (!draft.open || draft.target !== "chat") return null
+ if (draft.preparedChatDirectory) return draft.preparedChatDirectory
+
+ const runtimeKey = getRuntimeKey()
+ const key = `${runtimeKey}:${draft.draftId}`
+ const existing = pendingChatDirectoryByDraft.get(key)
+ if (existing) return existing
+
+ const pending = createChatDirectory().then(async (directory) => {
+ const current = get().newSessionDraft
+ if (
+ getRuntimeKey() !== runtimeKey
+ || !current.open
+ || current.target !== "chat"
+ || current.draftId !== draft.draftId
+ ) {
+ await deleteChatDirectory(directory).catch(() => undefined)
+ return null
+ }
+ set({ newSessionDraft: { ...current, preparedChatDirectory: directory } })
+ return directory
+ }).finally(() => {
+ pendingChatDirectoryByDraft.delete(key)
+ })
+ pendingChatDirectoryByDraft.set(key, pending)
+ return pending
+ },
+
// ---------------------------------------------------------------------------
// closeNewSessionDraft
// ---------------------------------------------------------------------------
closeNewSessionDraft: () => {
const currentDraft = get().newSessionDraft
+ if (currentDraft.preparedChatDirectory) {
+ void deleteChatDirectory(currentDraft.preparedChatDirectory).catch(() => undefined)
+ }
if (
!currentDraft.open
&& currentDraft.selectedProjectId == null
@@ -1100,18 +1174,21 @@ export const useSessionUIStore = create()((set, get) => ({
return
}
const nextDraft: NewSessionDraftState = {
- open: false,
- selectedProjectId: null,
- directoryOverride: null,
- pendingWorktreeRequestId: null,
- bootstrapPendingDirectory: null,
- preserveDirectoryOverride: false,
- parentID: null,
- title: undefined,
- initialPrompt: undefined,
- syntheticParts: undefined,
- targetFolderId: undefined,
- }
+ draftId: currentDraft.draftId,
+ open: false,
+ target: "chat",
+ preparedChatDirectory: null,
+ selectedProjectId: null,
+ directoryOverride: null,
+ pendingWorktreeRequestId: null,
+ bootstrapPendingDirectory: null,
+ preserveDirectoryOverride: false,
+ parentID: null,
+ title: undefined,
+ initialPrompt: undefined,
+ syntheticParts: undefined,
+ targetFolderId: undefined,
+ }
set({
newSessionDraft: nextDraft,
})
@@ -1119,14 +1196,21 @@ export const useSessionUIStore = create()((set, get) => ({
},
setNewSessionDraftTarget: (target) => {
+ if (isVSCodeRuntime() && target.projectId === CHAT_DRAFT_PROJECT_ID) return
+ const previousDraft = get().newSessionDraft
+ if (previousDraft.preparedChatDirectory && target.projectId !== CHAT_DRAFT_PROJECT_ID) {
+ void deleteChatDirectory(previousDraft.preparedChatDirectory).catch(() => undefined)
+ }
let nextDirectory: string | null = null
set((s) => {
nextDirectory = normalizePath(target.directoryOverride ?? s.newSessionDraft.directoryOverride)
return {
newSessionDraft: {
...s.newSessionDraft,
+ target: target.projectId === CHAT_DRAFT_PROJECT_ID ? "chat" : "project",
+ preparedChatDirectory: target.projectId === CHAT_DRAFT_PROJECT_ID ? s.newSessionDraft.preparedChatDirectory : null,
selectedProjectId: target.projectId ?? target.selectedProjectId ?? s.newSessionDraft.selectedProjectId,
- directoryOverride: target.directoryOverride ?? s.newSessionDraft.directoryOverride,
+ directoryOverride: target.projectId === CHAT_DRAFT_PROJECT_ID ? null : target.directoryOverride ?? s.newSessionDraft.directoryOverride,
},
}
})
diff --git a/packages/web/server/index.js b/packages/web/server/index.js
index 470c63f1..31d8ff26 100644
--- a/packages/web/server/index.js
+++ b/packages/web/server/index.js
@@ -1291,6 +1291,7 @@ const resolveMemoryProjectId = createMemoryProjectResolver({
return sanitizeProjects(settings?.projects || []).map((project) => project.path);
},
resolvePrimaryWorktreeRoot,
+ managedProjectRoots: [path.join(OPENCHAMBER_USER_CONFIG_ROOT, 'chats')],
});
/**
diff --git a/packages/web/server/lib/agent-memory/project-resolution.js b/packages/web/server/lib/agent-memory/project-resolution.js
index 85442897..03bb00de 100644
--- a/packages/web/server/lib/agent-memory/project-resolution.js
+++ b/packages/web/server/lib/agent-memory/project-resolution.js
@@ -24,7 +24,8 @@ const normalize = (value) => {
};
export const createMemoryProjectResolver = (dependencies) => {
- const { listProjectPaths, resolvePrimaryWorktreeRoot } = dependencies;
+ const { listProjectPaths, resolvePrimaryWorktreeRoot, managedProjectRoots = [] } = dependencies;
+ const managedRoots = managedProjectRoots.map(normalize).filter(Boolean);
return async (directory) => {
const resolved = normalize(directory);
@@ -32,6 +33,14 @@ export const createMemoryProjectResolver = (dependencies) => {
return '';
}
+ const managedRoot = managedRoots.find((root) => {
+ const relative = path.relative(root, resolved);
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
+ });
+ if (managedRoot) {
+ return createProjectIdFromPath(managedRoot);
+ }
+
let configured = [];
try {
configured = ((await listProjectPaths()) || []).map(normalize).filter(Boolean);
diff --git a/packages/web/server/lib/agent-memory/project-resolution.test.js b/packages/web/server/lib/agent-memory/project-resolution.test.js
index f38c2bbf..9ca6d098 100644
--- a/packages/web/server/lib/agent-memory/project-resolution.test.js
+++ b/packages/web/server/lib/agent-memory/project-resolution.test.js
@@ -51,6 +51,15 @@ describe('resolving a session directory to its project', () => {
expect(await resolve('/tmp/loose')).toBe(createProjectIdFromPath('/tmp/loose'));
});
+ test('managed chat session directories share the Chats root store', async () => {
+ const chatsRoot = '/Users/x/.config/openchamber/chats';
+ const resolve = createResolver({ managedProjectRoots: [chatsRoot] });
+
+ expect(await resolve(`${chatsRoot}/2026-08-21/session-a`)).toBe(createProjectIdFromPath(chatsRoot));
+ expect(await resolve(`${chatsRoot}/2026-08-21/session-b`)).toBe(createProjectIdFromPath(chatsRoot));
+ expect(await resolve('/Users/x/.config/openchamber/chats-other/session-a')).not.toBe(createProjectIdFromPath(chatsRoot));
+ });
+
test('no directory resolves to nothing rather than to some default project', async () => {
const resolve = createResolver();
diff --git a/packages/web/server/lib/project-context/DOCUMENTATION.md b/packages/web/server/lib/project-context/DOCUMENTATION.md
index 52a584d3..ecd0205a 100644
--- a/packages/web/server/lib/project-context/DOCUMENTATION.md
+++ b/packages/web/server/lib/project-context/DOCUMENTATION.md
@@ -3,6 +3,8 @@
Server-owned storage for the Project Notes surface: free-form notes, todos, and
plan markdown files.
+The managed Chats root (`~/.config/openchamber/chats`) is also one context owner. Every dated per-session directory beneath it resolves to that root, so Notes, Todo, Plans, pinned knowledge, and project memory are shared across ordinary chats without registering Chats as a user project.
+
## Ownership
| Path | Owner | Contents |
diff --git a/packages/web/server/lib/session-knowledge/DOCUMENTATION.md b/packages/web/server/lib/session-knowledge/DOCUMENTATION.md
index aa13a095..3aaeb9fd 100644
--- a/packages/web/server/lib/session-knowledge/DOCUMENTATION.md
+++ b/packages/web/server/lib/session-knowledge/DOCUMENTATION.md
@@ -24,6 +24,8 @@ attached to that session. Pins never come from project-wide note or plan state.
A new-session draft passes its pins into this metadata when its first message
creates the session.
+Directories beneath the managed `~/.config/openchamber/chats` root resolve to that root before project context and project memory are read. Every ordinary chat therefore shares one Chats knowledge owner instead of creating an unreachable context store for each dated session directory.
+
`session.metadata.openchamber.knowledge_context_delivered` holds the signature
of what the session is carrying. It lives with the session, so it survives the
tab closing and is visible to every sender, including the ones with no tab.