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
@@ -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 (
<div className="oc-draft-center flex min-h-0 flex-1 flex-col items-center justify-center px-6 text-center">
@@ -326,6 +326,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ 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<ChatInputProps> = ({ 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<ChatInputProps> = ({ onOpenSettings, scrollTo
React.useEffect(() => {
if (!showDraftTargetSelectors || !selectedDraftProject || !selectedDraftDirectory) {
if (!showDraftTargetSelectors || !selectedDraftProject || selectedDraftProject.kind === 'chat' || !selectedDraftDirectory) {
return;
}
if (newSessionDraft?.pendingWorktreeRequestId || newSessionDraft?.bootstrapPendingDirectory || newSessionDraft?.preserveDirectoryOverride) {
@@ -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<DraftTargetProject>(() => ({
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);
}
@@ -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' ? (
<Icon name="chat-4" className="h-3.5 w-3.5 shrink-0 text-muted-foreground/80" />
) : projectIconName ? (
<Icon name={projectIconName} className="h-3.5 w-3.5 shrink-0" style={iconColor ? { color: iconColor } : undefined} />
) : (
<Icon name="folder" className="h-3.5 w-3.5 shrink-0 text-muted-foreground/80" style={iconColor ? { color: iconColor } : undefined} />
@@ -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"
>
<SelectValue>
{<ProjectLabel project={selectedProject} theme={theme} />}
{selectedProject.kind === 'chat'
? <span className="truncate">{t('chat.chatInput.chooseProject')}</span>
: <ProjectLabel project={selectedProject} theme={theme} />}
</SelectValue>
</SelectTrigger>
<SelectContent fitContent>
{projects.map((project) => (
<SelectItem key={project.id} value={project.id} className="max-w-[24rem] truncate">
{<ProjectLabel project={project} theme={theme} />}
<ProjectLabel project={project} theme={theme} />
</SelectItem>
))}
</SelectContent>
@@ -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')}
>
{<ProjectLabel project={selectedProject} theme={theme} />}
{selectedProject.kind === 'chat'
? <span className="truncate">{t('chat.chatInput.chooseProject')}</span>
: <ProjectLabel project={selectedProject} theme={theme} />}
<Icon name="arrow-down-s" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
</button>
{showBranchSelector ? (
@@ -275,7 +281,7 @@ export function MobileDraftTargetSheets(
onOpenPickerChange(null);
}}
>
<span className="min-w-0 flex-1">{<ProjectLabel project={project} theme={theme} />}</span>
<span className="min-w-0 flex-1"><ProjectLabel project={project} theme={theme} /></span>
{project.id === selectedProject.id ? (
<Icon name="check" className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
) : null}