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
@@ -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<SessionSidebarProps> = ({
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<SessionSidebarProps> = ({
);
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<SessionSidebarProps> = ({
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<SessionSidebarProps> = ({
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<SessionSidebarProps> = ({
// 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<SessionSidebarProps> = ({
};
};
const items = recentSessions
const recentItems = showRecentSection ? recentSessions
.map(toItem)
.filter((item): item is NonNullable<ReturnType<typeof toItem>> => item !== null) : [];
const chatItems = chatSessions
.map(toItem)
.filter((item): item is NonNullable<ReturnType<typeof toItem>> => 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<SessionSidebarProps> = ({
],
);
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) ? (
<SidebarActivitySections
sections={activitySections}
renderSessionNode={renderSessionNode}
@@ -1746,9 +1760,11 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
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<SessionSidebarProps> = ({
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)
@@ -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.
@@ -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 (
<div key={section.key} className="relative space-y-1">
<div className={cn(
'relative group/chats',
'-ml-2.5 -mr-2',
stickyZoneHeaders && 'sticky top-0 z-20 bg-sidebar',
)} data-sidebar-sticky-header={stickyZoneHeaders ? 'true' : undefined}>
<button
type="button"
onClick={() => toggleSection(section.key)}
className="group flex w-full items-center gap-1.5 py-1 pl-4 pr-3.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
className={cn(
'group flex w-full items-center gap-1.5 py-1 pl-4 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
section.key === 'chats' && props.onNewChat ? 'pr-10' : 'pr-3.5',
)}
aria-expanded={!isCollapsed}
>
<span className="inline-flex h-3.5 w-3.5 items-center justify-center">
<Icon name="history" className={cn('h-3.5 w-3.5 text-muted-foreground/80', 'group-hover:hidden')} />
<Icon name={section.key === 'chats' ? 'chat-4' : 'history'} className={cn('h-3.5 w-3.5 text-muted-foreground/80', 'group-hover:hidden')} />
<span className="hidden h-3.5 w-3.5 items-center justify-center text-muted-foreground group-hover:inline-flex">
{isCollapsed ? <Icon name="arrow-right-s" className="h-3.5 w-3.5" /> : <Icon name="arrow-down-s" className="h-3.5 w-3.5" />}
</span>
</span>
<span className="text-[14px] font-semibold lowercase text-foreground">{section.title}</span>
</button>
{section.key === 'chats' && props.onNewChat ? (
<div className="absolute right-0.5 top-1/2 z-10 -translate-y-1/2">
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
props.onNewChat?.();
}}
className={cn(
'inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-opacity',
props.alwaysShowActions
? 'opacity-100'
: 'opacity-0 pointer-events-none group-hover/chats:opacity-100 group-hover/chats:pointer-events-auto group-focus-within/chats:opacity-100 group-focus-within/chats:pointer-events-auto',
)}
aria-label={t('sessions.sidebar.header.actions.newSession')}
>
<Icon name="add" className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
<p>{t('sessions.sidebar.header.actions.newSession')}</p>
</TooltipContent>
</Tooltip>
</div>
) : null}
</div>
{!isCollapsed ? (
<div className={cn('space-y-0.5')}>
@@ -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;
};
@@ -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]);
});
});
@@ -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;
}