From 9e87d7fdb9f293389baa012e2a4d28f4804a10c5 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 21 Aug 2026 12:12:40 +0300 Subject: [PATCH] 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. --- packages/electron/README.md | 1 + packages/ui/src/App.tsx | 14 +- packages/ui/src/apps/ElectronMiniChatApp.tsx | 18 ++- .../ui/src/components/chat/ChatContainer.tsx | 4 +- packages/ui/src/components/chat/ChatInput.tsx | 8 +- .../chat/composer/state/useDraftTarget.ts | 30 ++++- .../chat/composer/ui/DraftTargetSelectors.tsx | 16 ++- packages/ui/src/components/layout/Header.tsx | 21 ++- .../components/layout/RightSidebarTabs.tsx | 22 +++- .../components/mini-chat/MiniChatLayout.tsx | 22 +++- .../src/components/session/SessionSidebar.tsx | 75 ++++++----- .../session/sidebar/DOCUMENTATION.md | 2 +- .../sidebar/SidebarActivitySections.tsx | 44 ++++++- .../session/sidebar/hooks/useSwitcherItems.ts | 6 +- .../sidebar/sidebarSessionSources.test.ts | 28 ++++ .../session/sidebar/sidebarSessionSources.ts | 19 +++ .../src/hooks/useMiniChatKeyboardShortcuts.ts | 16 +-- packages/ui/src/lib/chatDirectories.test.ts | 55 ++++++++ packages/ui/src/lib/chatDirectories.ts | 88 +++++++++++++ packages/ui/src/lib/i18n/messages/de.ts | 2 + packages/ui/src/lib/i18n/messages/en.ts | 2 + packages/ui/src/lib/i18n/messages/es.ts | 2 + packages/ui/src/lib/i18n/messages/fr.ts | 2 + packages/ui/src/lib/i18n/messages/ja.ts | 2 + packages/ui/src/lib/i18n/messages/ko.ts | 2 + packages/ui/src/lib/i18n/messages/pl.ts | 2 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 2 + packages/ui/src/lib/i18n/messages/uk.ts | 2 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 2 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 2 + packages/ui/src/stores/globalSessions.test.ts | 26 +++- packages/ui/src/stores/globalSessions.ts | 7 + .../ui/src/stores/useGlobalSessionsStore.ts | 36 ++++- packages/ui/src/sync/DOCUMENTATION.md | 10 ++ .../ui/src/sync/__tests__/issue-2039.test.ts | 4 + packages/ui/src/sync/persist-cache.test.ts | 13 +- packages/ui/src/sync/persist-cache.ts | 18 +++ packages/ui/src/sync/session-actions.test.ts | 1 + packages/ui/src/sync/session-actions.ts | 18 +++ packages/ui/src/sync/session-ui-store.test.js | 20 +-- packages/ui/src/sync/session-ui-store.ts | 124 +++++++++++++++--- packages/web/server/index.js | 1 + .../lib/agent-memory/project-resolution.js | 11 +- .../agent-memory/project-resolution.test.js | 9 ++ .../lib/project-context/DOCUMENTATION.md | 2 + .../lib/session-knowledge/DOCUMENTATION.md | 2 + 46 files changed, 677 insertions(+), 136 deletions(-) create mode 100644 packages/ui/src/components/session/sidebar/sidebarSessionSources.test.ts create mode 100644 packages/ui/src/components/session/sidebar/sidebarSessionSources.ts create mode 100644 packages/ui/src/lib/chatDirectories.test.ts create mode 100644 packages/ui/src/lib/chatDirectories.ts diff --git a/packages/electron/README.md b/packages/electron/README.md index a7763d48..7a306894 100644 --- a/packages/electron/README.md +++ b/packages/electron/README.md @@ -143,6 +143,7 @@ Use an explicit override when testing a different OpenCode CLI build or when a u ## Native Features Owned Here - Floating Mini Chat windows. +- New Mini Chat windows default to the managed Chats target. Explicit project/worktree drafts retain their target, existing managed chat sessions reopen in their own directory, and the compact header omits project/branch metadata for Chats. Opening a managed draft back in the main window preserves that target. - Multiple native windows. - Native notifications. - User-confirmed local folder selection. The shared UI supplies the requested directory as the picker `defaultPath`; confirmation is required before filesystem access is retried. diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index ff0ffe7c..80085329 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -33,7 +33,6 @@ import type { RecoveryVariant } from '@/components/onboarding/DesktopConnectionR import { useSessionUIStore } from '@/sync/session-ui-store'; import { markSessionViewed } from '@/sync/notification-store'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; -import { useProjectsStore } from '@/stores/useProjectsStore'; import { opencodeClient } from '@/lib/opencode/client'; import { runtimeFetch } from '@/lib/runtime-fetch'; import { getRuntimeKey, subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch'; @@ -639,12 +638,9 @@ function App({ apis }: AppProps) { React.useEffect(() => { if (typeof window === 'undefined') return; const onOpenMiniChat = () => { - const currentDir = useDirectoryStore.getState().currentDirectory; - const { activeProjectId, projects } = useProjectsStore.getState(); - const activeProject = projects.find((p) => p.id === activeProjectId) ?? null; void invokeDesktop('desktop_open_draft_mini_chat_window', { - directory: currentDir || activeProject?.path || '', - projectId: activeProject?.id ?? null, + directory: '', + projectId: null, }); }; window.addEventListener('openchamber:open-mini-chat', onOpenMiniChat); @@ -676,11 +672,13 @@ function App({ apis }: AppProps) { const projectId = typeof detail?.projectId === 'string' && detail.projectId.trim().length > 0 ? detail.projectId.trim() : null; + const hasProjectTarget = Boolean(directory || projectId); useUIStore.getState().setActiveMainTab('chat'); useUIStore.getState().setSessionSwitcherOpen(false); useSessionUIStore.getState().openNewSessionDraft({ - selectedProjectId: projectId, - directoryOverride: directory, + target: hasProjectTarget ? 'project' : 'chat', + selectedProjectId: hasProjectTarget ? projectId : null, + directoryOverride: hasProjectTarget ? directory : null, preserveDirectoryOverride: Boolean(directory), }); }; diff --git a/packages/ui/src/apps/ElectronMiniChatApp.tsx b/packages/ui/src/apps/ElectronMiniChatApp.tsx index 10a993d8..d1d53b50 100644 --- a/packages/ui/src/apps/ElectronMiniChatApp.tsx +++ b/packages/ui/src/apps/ElectronMiniChatApp.tsx @@ -25,6 +25,7 @@ import { worktreeMapsEqual, } from '@/lib/worktrees/worktreeManager'; import type { WorktreeMetadata } from '@/types/worktree'; +import { CHAT_DRAFT_PROJECT_ID } from '@/lib/chatDirectories'; const MINI_CHAT_PRESENCE_CHANNEL = 'openchamber:mini-chat-presence'; @@ -153,9 +154,9 @@ const MiniChatBootstrap: React.FC<{ config: MiniChatConfig }> = ({ config }) => const sessionId = typeof detail?.sessionId === 'string' ? detail.sessionId.trim() : ''; if (!sessionId) return; if (useSessionUIStore.getState().currentSessionId === sessionId) return; - const directory = typeof detail?.directory === 'string' && detail.directory.trim().length > 0 - ? detail.directory.trim() - : (sessions.find((entry) => entry.id === sessionId) as { directory?: string | null } | undefined)?.directory ?? null; + const sessionDirectory = (sessions.find((entry) => entry.id === sessionId) as { directory?: string | null } | undefined)?.directory?.trim(); + const directory = sessionDirectory + || (typeof detail?.directory === 'string' && detail.directory.trim().length > 0 ? detail.directory.trim() : null); void sync.ensureSessionRenderable(sessionId); setCurrentSession(sessionId, directory); sessionBootstrappedRef.current = true; @@ -166,9 +167,11 @@ const MiniChatBootstrap: React.FC<{ config: MiniChatConfig }> = ({ config }) => React.useEffect(() => { if (config.mode !== 'draft' || draftOpen || currentSessionId) return; + const hasProjectTarget = Boolean(config.projectId || config.directory); openNewSessionDraft({ - selectedProjectId: config.projectId, - directoryOverride: config.directory, + target: hasProjectTarget ? 'project' : 'chat', + selectedProjectId: hasProjectTarget ? config.projectId : CHAT_DRAFT_PROJECT_ID, + directoryOverride: hasProjectTarget ? config.directory : null, preserveDirectoryOverride: Boolean(config.directory), }); }, [config, currentSessionId, draftOpen, openNewSessionDraft]); @@ -278,10 +281,11 @@ const MiniChatPresencePublisher: React.FC = () => { const useSessionUnavailable = (config: MiniChatConfig): boolean => { const sessions = useSessions(); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const draftOpen = useSessionUIStore((state) => state.newSessionDraft.open); const [timedOut, setTimedOut] = React.useState(false); React.useEffect(() => { - if (config.mode !== 'session' || !config.sessionId || currentSessionId === config.sessionId) { + if (draftOpen || config.mode !== 'session' || !config.sessionId || currentSessionId) { setTimedOut(false); return; } @@ -291,7 +295,7 @@ const useSessionUnavailable = (config: MiniChatConfig): boolean => { } const timeout = window.setTimeout(() => setTimedOut(true), 5000); return () => window.clearTimeout(timeout); - }, [config.mode, config.sessionId, currentSessionId, sessions]); + }, [config.mode, config.sessionId, currentSessionId, draftOpen, sessions]); return timedOut; }; diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index a50ea6e7..8b07222e 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -504,14 +504,16 @@ const renderDraftTitle = (title: string, projectLabel: string | null): React.Rea const DraftWelcome: React.FC = () => { const { t } = useI18n(); + const draftTarget = useSessionUIStore((state) => state.newSessionDraft.target); const selectedProjectId = useSessionUIStore((state) => state.newSessionDraft.selectedProjectId ?? null); const projectLabel = useProjectsStore(React.useCallback((state) => { + if (draftTarget === 'chat') return null; const projectId = selectedProjectId ?? state.activeProjectId; const project = (projectId ? state.projects.find((candidate) => candidate.id === projectId) : null) ?? state.projects[0] ?? null; return project ? getProjectDisplayLabel(project) : null; - }, [selectedProjectId])); + }, [draftTarget, selectedProjectId])); return (
diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 06f73900..fa98b7d0 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -326,6 +326,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const setNewSessionDraftTarget = useSessionUIStore((s) => s.setNewSessionDraftTarget); const setDraftPermissionAutoAcceptEnabled = useSessionUIStore((s) => s.setDraftPermissionAutoAcceptEnabled); const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft); + const prepareChatDraftDirectory = useSessionUIStore((s) => s.prepareChatDraftDirectory); const abortPromptSessionId = useSessionUIStore((s) => s.abortPromptSessionId); const clearAbortPrompt = useSessionUIStore((s) => s.clearAbortPrompt); const attachedFiles = useInputStore((s) => s.attachedFiles); @@ -336,6 +337,11 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const pendingPresetSubmit = useInputStore((s) => s.pendingPresetSubmit); const setPendingInputText = useInputStore((s) => s.setPendingInputText); const pendingInputText = useInputStore((s) => s.pendingInputText); + + React.useEffect(() => { + if (!newSessionDraftOpen || newSessionDraft.target !== 'chat' || message.trim().length === 0) return; + void prepareChatDraftDirectory(); + }, [message, newSessionDraft.target, newSessionDraftOpen, prepareChatDraftDirectory]); const consumePendingSyntheticParts = useInputStore((s) => s.consumePendingSyntheticParts); const acknowledgeSessionAbort = useSessionUIStore((s) => s.acknowledgeSessionAbort); const abortCurrentOperation = React.useCallback( @@ -2382,7 +2388,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo React.useEffect(() => { - if (!showDraftTargetSelectors || !selectedDraftProject || !selectedDraftDirectory) { + if (!showDraftTargetSelectors || !selectedDraftProject || selectedDraftProject.kind === 'chat' || !selectedDraftDirectory) { return; } if (newSessionDraft?.pendingWorktreeRequestId || newSessionDraft?.bootstrapPendingDirectory || newSessionDraft?.preserveDirectoryOverride) { diff --git a/packages/ui/src/components/chat/composer/state/useDraftTarget.ts b/packages/ui/src/components/chat/composer/state/useDraftTarget.ts index ae8cc408..d4586591 100644 --- a/packages/ui/src/components/chat/composer/state/useDraftTarget.ts +++ b/packages/ui/src/components/chat/composer/state/useDraftTarget.ts @@ -23,6 +23,8 @@ import { useProjectsStore } from '@/stores/useProjectsStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { buildSessionTargetOptions } from '@/sync/session-worktree-contract'; import { normalizePath } from '../attachments/filePaths'; +import { CHAT_DRAFT_PROJECT_ID } from '@/lib/chatDirectories'; +import { useI18n } from '@/lib/i18n'; /** How long a cached branch list is served before it is refreshed. */ const BRANCHES_SWR_TTL_MS = 30_000; @@ -35,6 +37,7 @@ export interface DraftTargetProject { color?: string | null; iconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' } | null; iconBackground?: string | null; + kind?: 'chat' | 'project'; } /** A project's display name, falling back to its directory name. */ @@ -43,7 +46,15 @@ export function getProjectDisplayLabel(project: { label?: string; path: string } } export function useDraftTarget(enabled: boolean) { - const projects = useProjectsStore((state) => state.projects) as DraftTargetProject[]; + const configuredProjects: readonly DraftTargetProject[] = useProjectsStore((state) => state.projects); + const { t } = useI18n(); + const chatProject = React.useMemo(() => ({ + id: CHAT_DRAFT_PROJECT_ID, + path: '', + label: t('layout.mainTab.chat'), + kind: 'chat', + }), [t]); + const projects = React.useMemo(() => [chatProject, ...configuredProjects], [chatProject, configuredProjects]); const activeProjectId = useProjectsStore((state) => state.activeProjectId); const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly); const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft); @@ -53,6 +64,7 @@ export function useDraftTarget(enabled: boolean) { const { git: runtimeGit } = useRuntimeAPIs(); const selectedDraftProject = React.useMemo(() => { + if (newSessionDraft?.target === 'chat') return chatProject; const explicit = newSessionDraft?.selectedProjectId ? projects.find((project) => project.id === newSessionDraft.selectedProjectId) ?? null : null; @@ -67,14 +79,16 @@ export function useDraftTarget(enabled: boolean) { return active; } - return projects[0] ?? null; - }, [activeProjectId, newSessionDraft?.selectedProjectId, projects]); + return configuredProjects[0] ?? chatProject; + }, [activeProjectId, chatProject, configuredProjects, newSessionDraft?.selectedProjectId, newSessionDraft?.target, projects]); const selectedDraftProjectPath = React.useMemo( - () => normalizePath(selectedDraftProject?.path ?? null), - [selectedDraftProject?.path], + () => selectedDraftProject?.kind === 'chat' ? null : normalizePath(selectedDraftProject?.path ?? null), + [selectedDraftProject?.kind, selectedDraftProject?.path], ); - const draftProjectLabel = selectedDraftProject ? getProjectDisplayLabel(selectedDraftProject) : null; + const draftProjectLabel = selectedDraftProject && selectedDraftProject.kind !== 'chat' + ? getProjectDisplayLabel(selectedDraftProject) + : null; const selectedDraftProjectBranches = useGitBranches(selectedDraftProjectPath); const selectedDraftProjectBranchesFetchedAt = useGitStore( @@ -258,6 +272,10 @@ export function useDraftTarget(enabled: boolean) { if (!project) { return; } + if (project.kind === 'chat') { + setNewSessionDraftTarget({ projectId: CHAT_DRAFT_PROJECT_ID, directoryOverride: null }, { force: true }); + return; + } if (activeProjectId !== projectId) { setActiveProjectIdOnly(projectId); } diff --git a/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx b/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx index e3cefb15..35072c6c 100644 --- a/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx +++ b/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx @@ -57,7 +57,9 @@ const getProjectIconColor = (projectColor?: string | null): string | undefined = function ProjectLabel({ project, theme }: { project: DraftTargetProject; theme: Theme }) { const projectIconName = project.icon ? PROJECT_ICON_MAP[project.icon] : null; const iconColor = getProjectIconColor(project.color); - const fallbackIcon = projectIconName ? ( + const fallbackIcon = project.kind === 'chat' ? ( + + ) : projectIconName ? ( ) : ( @@ -115,13 +117,15 @@ export function DraftTargetSelectors(props: DraftTargetProps) { className="h-7 min-w-0 w-fit max-w-[42vw] sm:max-w-[18rem] border-transparent bg-transparent px-1.5 hover:bg-transparent data-[popup-open]:bg-transparent" > - {} + {selectedProject.kind === 'chat' + ? {t('chat.chatInput.chooseProject')} + : } {projects.map((project) => ( - {} + ))} @@ -195,7 +199,9 @@ export function MobileDraftTargetTriggers( className="inline-flex h-7 min-w-0 max-w-[42vw] flex-shrink cursor-pointer items-center gap-1 rounded-lg px-1.5 typography-micro font-medium text-foreground/80 hover:bg-[var(--interactive-hover)]" onClick={() => onOpenPicker('project')} > - {} + {selectedProject.kind === 'chat' + ? {t('chat.chatInput.chooseProject')} + : } {showBranchSelector ? ( @@ -275,7 +281,7 @@ export function MobileDraftTargetSheets( onOpenPickerChange(null); }} > - {} + {project.id === selectedProject.id ? ( ) : null} diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 37d9cac8..1dfa2275 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -22,6 +22,8 @@ import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionWorktreeStore } from '@/sync/session-worktree-store'; import { formatSessionWorktreeBadge } from '@/sync/session-worktree-contract'; import { buildSessionMessageRecordsSnapshot, useDirectoryStore, useGlobalSessionStatus, useSessionMessagesResolved } from '@/sync/sync-context'; +import { useDirectoryStore as useAppDirectoryStore } from '@/stores/useDirectoryStore'; +import { isChatDirectoryForHome } from '@/lib/chatDirectories'; import { useSync } from '@/sync/use-sync'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore'; @@ -1052,6 +1054,10 @@ export const Header: React.FC = ({ } return normalize(state.newSessionDraft.bootstrapPendingDirectory ?? state.newSessionDraft.directoryOverride ?? ''); }); + const draftTarget = useSessionUIStore((state) => state.newSessionDraft.target); + const draftProjectId = useSessionUIStore((state) => state.newSessionDraft.selectedProjectId); + const selectedSessionDirectory = useSessionUIStore((state) => state.currentSessionDirectory); + const homeDirectory = useAppDirectoryStore((state) => state.homeDirectory); const openDirectory = React.useMemo(() => { return worktreeDirectory || sessionDirectory || draftDirectory; @@ -1080,10 +1086,13 @@ export const Header: React.FC = ({ const gitBranchForDirectory = useGitBranchLabel(openDirectory || null); const currentBranchLabel = gitBranchForDirectory || currentSessionWorktreeBranch || catalogWorktreeBranch; + const isChatContext = isNewSessionDraftOpen + ? draftTarget === 'chat' + : isChatDirectoryForHome(sessionDirectory || selectedSessionDirectory, homeDirectory); // Whether the title carries a second line under it. Hoisted because the // session menu's vertical alignment depends on the same answer. - const showHeaderMetaRow = !workStatusPanelVisible + const showHeaderMetaRow = !isChatContext && !workStatusPanelVisible && Boolean(activeProjectLabel || currentBranchLabel || (!isNewSessionDraftOpen && worktreeBadgeKind)); @@ -1423,14 +1432,14 @@ export const Header: React.FC = ({ const handleOpenDraftMiniChat = React.useCallback(() => { void invokeDesktop('desktop_open_draft_mini_chat_window', { - directory: normalize(openDirectory || activeProject?.path || ''), - projectId: activeProject?.id ?? null, + directory: isChatContext ? '' : draftDirectory, + projectId: isChatContext ? null : draftProjectId, apiBaseUrl: getRuntimeApiBaseUrl(), clientToken: getRuntimeBearerTokenSync(), }).catch((error) => { console.warn('[header] failed to open draft mini chat window', error); }); - }, [activeProject?.id, activeProject?.path, openDirectory]); + }, [draftDirectory, draftProjectId, isChatContext]); const handleOpenCurrentMiniChat = React.useCallback(() => { if (isNewSessionDraftOpen) { @@ -1443,13 +1452,13 @@ export const Header: React.FC = ({ } void invokeDesktop('desktop_open_session_mini_chat_window', { sessionId: currentSessionId, - directory: normalize(openDirectory || activeProject?.path || ''), + directory: sessionDirectory || normalize(selectedSessionDirectory || '') || worktreeDirectory, apiBaseUrl: getRuntimeApiBaseUrl(), clientToken: getRuntimeBearerTokenSync(), }).catch((error) => { console.warn('[header] failed to open session mini chat window', error); }); - }, [activeProject?.path, currentSessionId, handleOpenDraftMiniChat, isNewSessionDraftOpen, openDirectory]); + }, [currentSessionId, handleOpenDraftMiniChat, isNewSessionDraftOpen, selectedSessionDirectory, sessionDirectory, worktreeDirectory]); const handleOpenContextPanel = React.useCallback(() => { const directory = normalize(openDirectory || ''); diff --git a/packages/ui/src/components/layout/RightSidebarTabs.tsx b/packages/ui/src/components/layout/RightSidebarTabs.tsx index cd8b2f1e..8138bef1 100644 --- a/packages/ui/src/components/layout/RightSidebarTabs.tsx +++ b/packages/ui/src/components/layout/RightSidebarTabs.tsx @@ -5,6 +5,9 @@ import { useGitStore } from '@/stores/useGitStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { formatDirectoryName } from '@/lib/utils'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { CHAT_DRAFT_PROJECT_ID, getChatsRootForHome, getChatsRootFromDirectory, isChatDirectoryPath } from '@/lib/chatDirectories'; +import { useI18n } from '@/lib/i18n'; export const ProjectContextPanel: React.FC<{ onActionComplete?: () => void; @@ -13,16 +16,28 @@ export const ProjectContextPanel: React.FC<{ const activeProjectId = useProjectsStore((state) => state.activeProjectId); const projects = useProjectsStore((state) => state.projects); const homeDirectory = useDirectoryStore((state) => state.homeDirectory); + const { t } = useI18n(); const gitDirectories = useGitStore((state) => state.directories); + const isChatContext = useSessionUIStore((state) => ( + state.newSessionDraft.open + ? state.newSessionDraft.target === 'chat' + : isChatDirectoryPath(state.currentSessionDirectory) + )); + const chatSessionDirectory = useSessionUIStore((state) => state.currentSessionDirectory); + const chatsRoot = getChatsRootFromDirectory(chatSessionDirectory) ?? getChatsRootForHome(homeDirectory); const activeProject = React.useMemo(() => { + if (isChatContext) return null; if (activeProjectId) { return projects.find((project) => project.id === activeProjectId) ?? projects[0] ?? null; } return projects[0] ?? null; - }, [activeProjectId, projects]); + }, [activeProjectId, isChatContext, projects]); const projectRef = React.useMemo(() => { + if (isChatContext && chatsRoot) { + return { id: CHAT_DRAFT_PROJECT_ID, path: chatsRoot }; + } if (!activeProject) { return null; } @@ -30,16 +45,17 @@ export const ProjectContextPanel: React.FC<{ id: activeProject.id, path: activeProject.path, }; - }, [activeProject]); + }, [activeProject, chatsRoot, isChatContext]); const projectLabel = React.useMemo(() => { + if (isChatContext) return t('sessions.sidebar.activity.chatsTitle'); if (!activeProject) { return null; } return activeProject.label?.trim() || formatDirectoryName(activeProject.path, homeDirectory) || activeProject.path; - }, [activeProject, homeDirectory]); + }, [activeProject, homeDirectory, isChatContext, t]); const canCreateWorktree = React.useMemo(() => { if (!activeProject) { diff --git a/packages/ui/src/components/mini-chat/MiniChatLayout.tsx b/packages/ui/src/components/mini-chat/MiniChatLayout.tsx index a32779a1..6e4eeec8 100644 --- a/packages/ui/src/components/mini-chat/MiniChatLayout.tsx +++ b/packages/ui/src/components/mini-chat/MiniChatLayout.tsx @@ -20,6 +20,7 @@ import { Icon } from "@/components/icon/Icon"; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { contextTokensFromBreakdown } from '@/stores/utils/tokenUtils'; import type { SessionContextUsage } from '@/stores/types/sessionTypes'; +import { isChatDirectoryPath } from '@/lib/chatDirectories'; type MiniChatMode = 'session' | 'draft'; @@ -51,6 +52,7 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => { const { t } = useI18n(); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const draftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open)); + const draftTarget = useSessionUIStore((state) => state.newSessionDraft.target); const draftProjectId = useSessionUIStore((state) => state.newSessionDraft?.selectedProjectId ?? null); const currentDirectory = useDirectoryStore((state) => state.currentDirectory); const projects = useProjectsStore((state) => state.projects); @@ -99,6 +101,7 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => { const worktreeDirectory = normalizePath(worktreePath || sessionWorktreeMetadata?.path || worktreeAttachment?.cwd || worktreeAttachment?.worktreeRoot || ''); const currentDirectoryNormalized = normalizePath(currentDirectory); const openDirectory = worktreeDirectory || sessionDirectory || draftDirectory || currentDirectoryNormalized; + const isChatContext = draftOpen ? draftTarget === 'chat' : isChatDirectoryPath(sessionDirectory); const directoryLabel = compactPath(openDirectory); const catalogWorktreeBranch = useSessionUIStore((state) => { const candidateDirectory = normalizePath(worktreeDirectory || sessionDirectory || ''); @@ -111,9 +114,9 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => { return null; }); React.useEffect(() => { - if (!openDirectory) return; + if (!openDirectory || isChatContext) return; void ensureGitStatus(openDirectory, runtimeApis.git).catch(() => {}); - }, [ensureGitStatus, openDirectory, runtimeApis.git]); + }, [ensureGitStatus, isChatContext, openDirectory, runtimeApis.git]); const pathMatchedProject = React.useMemo(() => { const projectDirectory = normalizePath(sessionWorktreeMetadata?.projectDirectory ?? worktreeAttachment?.worktreeRoot ?? null); @@ -125,13 +128,14 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => { .sort((left, right) => right.path.length - left.path.length)[0] ?? null; }, [openDirectory, projects, sessionWorktreeMetadata?.projectDirectory, worktreeAttachment?.worktreeRoot]); const projectLabel = React.useMemo(() => { + if (isChatContext) return null; const project = pathMatchedProject ?? activeProject; if (!project) return directoryLabel || 'OpenChamber'; const label = project.label?.trim(); if (label) return label; const segments = project.path.split(/[\\/]/).filter(Boolean); return segments.at(-1) ?? project.path; - }, [activeProject, directoryLabel, pathMatchedProject]); + }, [activeProject, directoryLabel, isChatContext, pathMatchedProject]); const gitBranchForDirectory = useGitBranchLabel(openDirectory || null); const rawBranchLabel = gitBranchForDirectory || worktreeMetadataBranch || sessionWorktreeMetadata?.branch?.trim() || worktreeAttachment?.branch?.trim() || catalogWorktreeBranch; const branchLabel = rawBranchLabel && rawBranchLabel !== 'HEAD' ? rawBranchLabel : null; @@ -241,7 +245,11 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => { const handleOpenMainApp = React.useCallback(() => { const payload = currentSessionId ? { sessionId: currentSessionId, directory: (session as { directory?: string | null } | null)?.directory ?? currentDirectory ?? '' } - : { mode: 'draft', directory: openDirectory || currentDirectory || '', projectId: draftProjectId }; + : { + mode: 'draft', + directory: isChatContext ? '' : openDirectory || currentDirectory || '', + projectId: isChatContext ? null : draftProjectId, + }; void invokeDesktop<{ focused?: boolean }>('desktop_focus_main_window', payload) .then((result) => { if (result?.focused === true) { @@ -249,7 +257,7 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => { } return null; }); - }, [currentDirectory, currentSessionId, draftProjectId, openDirectory, session]); + }, [currentDirectory, currentSessionId, draftProjectId, isChatContext, openDirectory, session]); return (
= ({ mode }) => { {title} - + {!isChatContext ? {projectLabel} {branchLabel ? ( @@ -281,7 +289,7 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => { {branchLabel} ) : null} - + : null}
diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index 1d14494f..11922074 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -1,4 +1,6 @@ import React from 'react'; +import { isChatDirectoryForHome, isChatDirectoryPath } from '@/lib/chatDirectories'; +import { mergeSidebarSessionSources } from './sidebar/sidebarSessionSources'; import type { Session } from '@opencode-ai/sdk/v2'; import { toast } from '@/components/ui'; import { useI18n } from '@/lib/i18n'; @@ -439,6 +441,7 @@ const SessionSidebarComponent: React.FC = ({ const liveSessionIndex = getAllSyncSessionMap(); const liveSessions = React.useMemo(() => Array.from(liveSessionIndex.values()), [liveSessionIndex]); const isVSCode = React.useMemo(() => isVSCodeRuntime(), []); + const runtimeKey = getRuntimeKey(); const hasAuthoritativeGlobalSessions = useGlobalSessionsStore((state) => state.status === 'ready'); const activeSessionStructure = useGlobalSessionsStore(useShallow( (state) => state.activeSessions.map(getSessionStructuralSignature).sort(), @@ -506,20 +509,15 @@ const SessionSidebarComponent: React.FC = ({ ); const sessions = React.useMemo(() => { - const merged = [...globalActiveSessions]; - const seenIds = new Set(merged.map((session) => session.id)); + const merged = mergeSidebarSessionSources(globalActiveSessions, liveFallbackSessions); - liveFallbackSessions.forEach((session) => { - if (seenIds.has(session.id)) { - return; - } - merged.push(session); - }); - - return merged.filter((session) => isKnownActiveSessionDirectory(session, knownSessionDirectories, { - allowUnknownDirectory: !isVSCode, - allowEmptyDirectorySet: !isVSCode, - })); + return merged.filter((session) => ( + (!isVSCode && isChatDirectoryPath(session.directory)) + || isKnownActiveSessionDirectory(session, knownSessionDirectories, { + allowUnknownDirectory: !isVSCode, + allowEmptyDirectorySet: !isVSCode, + }) + )); }, [globalActiveSessions, isVSCode, knownSessionDirectories, liveFallbackSessions]); const persistenceSessions = React.useMemo( @@ -532,7 +530,6 @@ const SessionSidebarComponent: React.FC = ({ syncSessionsSnapshotRef.current = liveSessions; }, [liveSessions]); - const runtimeKey = getRuntimeKey(); const projectWorktreeDiscoveryKey = React.useMemo( () => `${runtimeKey}|${projects .map((project) => `${project.id}:${normalizePath(project.path) ?? ''}`) @@ -1369,9 +1366,13 @@ const SessionSidebarComponent: React.FC = ({ return []; } - return deriveRecentSessions(sessions, activeSessionIdSet) + return deriveRecentSessions(sessions.filter((session) => !isChatDirectoryForHome(session.directory, homeDirectory)), activeSessionIdSet) .sort((a, b) => compareSessionsByLifecycleOrder(a, b, pinnedSessionIds, sessionOrderRanks)); - }, [activeSessionIdSet, isVSCode, pinnedSessionIds, sessionOrderRanks, sessions, showRecentSection]); + }, [activeSessionIdSet, homeDirectory, isVSCode, pinnedSessionIds, sessionOrderRanks, sessions, showRecentSection]); + + const chatSessions = React.useMemo(() => sessions + .filter((session) => !session.parentID && !session.time?.archived && isChatDirectoryForHome(session.directory, homeDirectory)) + .sort((a, b) => compareSessionsByLifecycleOrder(a, b, pinnedSessionIds, sessionOrderRanks)), [homeDirectory, pinnedSessionIds, sessionOrderRanks, sessions]); // Prefetch is wired below, after recentSessions is computed. @@ -1379,13 +1380,13 @@ const SessionSidebarComponent: React.FC = ({ // VS Code renders the full grouped project view (one group per open // workspace, folders + pinned native); the flat "recent" activity list is // web/desktop-only. - if (isVSCode || !showRecentSection) { + if (isVSCode) { return []; } const toItem = (session: Session) => { const existing = sessionSidebarMetaById.get(session.id); - const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null); + const sessionDirectory = normalizePath(session.directory ?? null); const node = existing?.node ?? { session, children: [], worktree: null }; const filteredNodes = hasSessionSearchQuery ? filterSessionNodesForSearch([node], normalizedSessionSearchQuery) @@ -1408,17 +1409,21 @@ const SessionSidebarComponent: React.FC = ({ }; }; - const items = recentSessions + const recentItems = showRecentSection ? recentSessions + .map(toItem) + .filter((item): item is NonNullable> => item !== null) : []; + + const chatItems = chatSessions .map(toItem) .filter((item): item is NonNullable> => item !== null); - return [ - { key: 'active-now' as const, title: t('sessions.sidebar.activity.recentTitle'), items }, + { key: 'chats' as const, title: t('sessions.sidebar.activity.chatsTitle'), items: chatItems }, + { key: 'active-now' as const, title: t('sessions.sidebar.activity.recentTitle'), items: recentItems }, ]; - }, [filterSessionNodesForSearch, hasSessionSearchQuery, isVSCode, normalizedSessionSearchQuery, recentSessions, sessionSidebarMetaById, showRecentSection, t]); + }, [chatSessions, filterSessionNodesForSearch, hasSessionSearchQuery, isVSCode, normalizedSessionSearchQuery, recentSessions, sessionSidebarMetaById, showRecentSection, t]); const hasActivitySectionItems = React.useMemo( - () => activitySections.some((section) => section.items.length > 0), + () => activitySections.some((section) => section.key === 'chats' || section.items.length > 0), [activitySections], ); @@ -1736,8 +1741,17 @@ const SessionSidebarComponent: React.FC = ({ ], ); + const handleOpenNewSessionDraftFromHeader = React.useCallback(() => { + useUIStore.getState().closeMainSurfaces(); + setActiveMainTab('chat'); + if (mobileVariant) { + setSessionSwitcherOpen(false); + } + openNewSessionDraft(); + }, [mobileVariant, openNewSessionDraft, setActiveMainTab, setSessionSwitcherOpen]); + const topContent = React.useMemo( - () => (!isVSCode && showRecentSection && !hasSessionSearchQuery) ? ( + () => (!isVSCode && !hasSessionSearchQuery && hasActivitySectionItems) ? ( = ({ expansionState={recentExpandedParents} variant="section" isDesktopShellRuntime={isDesktopShellRuntime} + onNewChat={handleOpenNewSessionDraftFromHeader} + alwaysShowActions={alwaysShowSidebarActions} /> ) : null, - [activitySections, editingId, hasSessionSearchQuery, isDesktopShellRuntime, isVSCode, openSidebarMenuKey, recentExpandedParents, renderSessionNode, showRecentSection], + [activitySections, alwaysShowSidebarActions, editingId, handleOpenNewSessionDraftFromHeader, hasActivitySectionItems, hasSessionSearchQuery, isDesktopShellRuntime, isVSCode, openSidebarMenuKey, recentExpandedParents, renderSessionNode], ); const isInlineEditing = Boolean(renamingFolderId || editingId || editingProjectDialogId); @@ -1789,15 +1805,6 @@ const SessionSidebarComponent: React.FC = ({ openMultiRunLauncher(); }, [mobileVariant, openMultiRunLauncher, setActiveMainTab, setSessionSwitcherOpen]); - const handleOpenNewSessionDraftFromHeader = React.useCallback(() => { - useUIStore.getState().closeMainSurfaces(); - setActiveMainTab('chat'); - if (mobileVariant) { - setSessionSwitcherOpen(false); - } - openNewSessionDraft(); - }, [mobileVariant, openNewSessionDraft, setActiveMainTab, setSessionSwitcherOpen]); - return ( // One shared tooltip provider for the whole sidebar: session tooltips open // instantly, and moving between rows hands the tooltip over (grouping) diff --git a/packages/ui/src/components/session/sidebar/DOCUMENTATION.md b/packages/ui/src/components/session/sidebar/DOCUMENTATION.md index ddaf8840..42bf3fb8 100644 --- a/packages/ui/src/components/session/sidebar/DOCUMENTATION.md +++ b/packages/ui/src/components/session/sidebar/DOCUMENTATION.md @@ -29,7 +29,7 @@ - `SidebarHeader.tsx`: Top header UI for add-project, session search, selection mode, project sort, and the display menu (recent toggle, collapse/expand all). - A successful add/create/clone from the project-directory dialog transitions to a new-session draft targeted at that project, matching the project's `+` action; changing project metadata alone must not leave the visible session or draft on a different directory. - `SidebarNav.tsx`: Text navigation rows above the tree (New session, Scheduled, Multi-run, Archive); hidden in VS Code. -- `SidebarActivitySections.tsx`: Global top section renderer; currently used for the `recent` section only, styled as a zone header. +- `SidebarActivitySections.tsx`: Global top section renderer for project-only `recent` sessions followed by OpenChamber-managed `chats`, styled as zone headers. - `SidebarFooter.tsx`: Static footer with icon-only settings, shortcuts, and about actions. - `SidebarProjectsList.tsx`: Main scrollable renderer for project zones and their flat/archived groups plus empty/search states; owns project drag-to-reorder. - `SessionGroupSection.tsx`: Renders one flat (or archived) group: sessions first, then flat folder entries with path labels, show-more batching, and explicit loading/error/retry state for empty groups. Archived buckets (VS Code) virtualize past 50 rows. diff --git a/packages/ui/src/components/session/sidebar/SidebarActivitySections.tsx b/packages/ui/src/components/session/sidebar/SidebarActivitySections.tsx index 6387552c..d3f95a35 100644 --- a/packages/ui/src/components/session/sidebar/SidebarActivitySections.tsx +++ b/packages/ui/src/components/session/sidebar/SidebarActivitySections.tsx @@ -10,6 +10,7 @@ import { resolveMenuOpenSessionId, } from './sessionNodeItemUtils'; import type { SessionNodeRenderExtras } from './sessionNodeItemUtils'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; type ActivityItem = { node: SessionNode; @@ -22,7 +23,7 @@ type ActivityItem = { }; type ActivitySection = { - key: 'active-now'; + key: 'active-now' | 'chats'; title: string; items: ActivityItem[]; }; @@ -46,6 +47,8 @@ type Props = { initialVisibleCount?: number; batchSize?: number; isDesktopShellRuntime: boolean; + onNewChat?: () => void; + alwaysShowActions?: boolean; }; type RenderExtras = SessionNodeRenderExtras; @@ -129,7 +132,9 @@ export function SidebarActivitySections(props: Props): React.ReactNode { }); }, [editingId, openSidebarMenuKey]); - const visibleSections = sections.filter((section) => section.items.length > 0); + const visibleSections = sections.filter((section) => ( + section.items.length > 0 || (section.key === 'chats' && props.onNewChat) + )); if (visibleSections.length === 0) { return null; } @@ -179,23 +184,54 @@ export function SidebarActivitySections(props: Props): React.ReactNode { return (
+ {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.