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