feat(chats): add managed projectless chat sessions

Create projectless chat sessions under a managed, date-scoped Chats directory and clean abandoned or deleted session folders.

Add Chats to sidebar state, startup cache, shared context, and Electron Mini Chat while keeping VS Code project-only. Resolve managed chat directories to one server-side memory owner and document the runtime contracts.
This commit is contained in:
Bohdan Triapitsyn
2026-08-21 12:12:40 +03:00
parent 0d70a631f6
commit 9e87d7fdb9
46 changed files with 677 additions and 136 deletions
+10
View File
@@ -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-<id>` 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
@@ -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",
},
})
})
+12 -1
View File
@@ -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))
+18
View File
@@ -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)
@@ -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 } })
+18
View File
@@ -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<void> {
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<boolean> {
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
+11 -9
View File
@@ -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);
+104 -20
View File
@@ -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<NewSessionDraftState> & { automatic?: boolean }) => void
prepareChatDraftDirectory: () => Promise<string | null>
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<string, Promise<string | null>>()
const activeSessionByRuntime = new Map<string, string | null>()
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<SessionUIState>()((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<SessionUIState>()((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<SessionUIState>()((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<SessionUIState>()((set, get) => ({
}
set({
newSessionDraft: {
...nextDraft,
},
newSessionDraft: nextDraft,
currentSessionId: null,
currentSessionDirectory: null,
error: null,
@@ -1078,11 +1119,44 @@ export const useSessionUIStore = create<SessionUIState>()((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<SessionUIState>()((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<SessionUIState>()((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,
},
}
})