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:
@@ -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",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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 } })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user