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}
+15 -6
View File
@@ -22,6 +22,8 @@ import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessionWorktreeStore } from '@/sync/session-worktree-store';
import { formatSessionWorktreeBadge } from '@/sync/session-worktree-contract';
import { buildSessionMessageRecordsSnapshot, useDirectoryStore, useGlobalSessionStatus, useSessionMessagesResolved } from '@/sync/sync-context';
import { useDirectoryStore as useAppDirectoryStore } from '@/stores/useDirectoryStore';
import { isChatDirectoryForHome } from '@/lib/chatDirectories';
import { useSync } from '@/sync/use-sync';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore';
@@ -1052,6 +1054,10 @@ export const Header: React.FC<HeaderProps> = ({
}
return normalize(state.newSessionDraft.bootstrapPendingDirectory ?? state.newSessionDraft.directoryOverride ?? '');
});
const draftTarget = useSessionUIStore((state) => state.newSessionDraft.target);
const draftProjectId = useSessionUIStore((state) => state.newSessionDraft.selectedProjectId);
const selectedSessionDirectory = useSessionUIStore((state) => state.currentSessionDirectory);
const homeDirectory = useAppDirectoryStore((state) => state.homeDirectory);
const openDirectory = React.useMemo(() => {
return worktreeDirectory || sessionDirectory || draftDirectory;
@@ -1080,10 +1086,13 @@ export const Header: React.FC<HeaderProps> = ({
const gitBranchForDirectory = useGitBranchLabel(openDirectory || null);
const currentBranchLabel = gitBranchForDirectory || currentSessionWorktreeBranch || catalogWorktreeBranch;
const isChatContext = isNewSessionDraftOpen
? draftTarget === 'chat'
: isChatDirectoryForHome(sessionDirectory || selectedSessionDirectory, homeDirectory);
// Whether the title carries a second line under it. Hoisted because the
// session menu's vertical alignment depends on the same answer.
const showHeaderMetaRow = !workStatusPanelVisible
const showHeaderMetaRow = !isChatContext && !workStatusPanelVisible
&& Boolean(activeProjectLabel || currentBranchLabel || (!isNewSessionDraftOpen && worktreeBadgeKind));
@@ -1423,14 +1432,14 @@ export const Header: React.FC<HeaderProps> = ({
const handleOpenDraftMiniChat = React.useCallback(() => {
void invokeDesktop('desktop_open_draft_mini_chat_window', {
directory: normalize(openDirectory || activeProject?.path || ''),
projectId: activeProject?.id ?? null,
directory: isChatContext ? '' : draftDirectory,
projectId: isChatContext ? null : draftProjectId,
apiBaseUrl: getRuntimeApiBaseUrl(),
clientToken: getRuntimeBearerTokenSync(),
}).catch((error) => {
console.warn('[header] failed to open draft mini chat window', error);
});
}, [activeProject?.id, activeProject?.path, openDirectory]);
}, [draftDirectory, draftProjectId, isChatContext]);
const handleOpenCurrentMiniChat = React.useCallback(() => {
if (isNewSessionDraftOpen) {
@@ -1443,13 +1452,13 @@ export const Header: React.FC<HeaderProps> = ({
}
void invokeDesktop('desktop_open_session_mini_chat_window', {
sessionId: currentSessionId,
directory: normalize(openDirectory || activeProject?.path || ''),
directory: sessionDirectory || normalize(selectedSessionDirectory || '') || worktreeDirectory,
apiBaseUrl: getRuntimeApiBaseUrl(),
clientToken: getRuntimeBearerTokenSync(),
}).catch((error) => {
console.warn('[header] failed to open session mini chat window', error);
});
}, [activeProject?.path, currentSessionId, handleOpenDraftMiniChat, isNewSessionDraftOpen, openDirectory]);
}, [currentSessionId, handleOpenDraftMiniChat, isNewSessionDraftOpen, selectedSessionDirectory, sessionDirectory, worktreeDirectory]);
const handleOpenContextPanel = React.useCallback(() => {
const directory = normalize(openDirectory || '');
@@ -5,6 +5,9 @@ import { useGitStore } from '@/stores/useGitStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { formatDirectoryName } from '@/lib/utils';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { CHAT_DRAFT_PROJECT_ID, getChatsRootForHome, getChatsRootFromDirectory, isChatDirectoryPath } from '@/lib/chatDirectories';
import { useI18n } from '@/lib/i18n';
export const ProjectContextPanel: React.FC<{
onActionComplete?: () => void;
@@ -13,16 +16,28 @@ export const ProjectContextPanel: React.FC<{
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
const projects = useProjectsStore((state) => state.projects);
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
const { t } = useI18n();
const gitDirectories = useGitStore((state) => state.directories);
const isChatContext = useSessionUIStore((state) => (
state.newSessionDraft.open
? state.newSessionDraft.target === 'chat'
: isChatDirectoryPath(state.currentSessionDirectory)
));
const chatSessionDirectory = useSessionUIStore((state) => state.currentSessionDirectory);
const chatsRoot = getChatsRootFromDirectory(chatSessionDirectory) ?? getChatsRootForHome(homeDirectory);
const activeProject = React.useMemo(() => {
if (isChatContext) return null;
if (activeProjectId) {
return projects.find((project) => project.id === activeProjectId) ?? projects[0] ?? null;
}
return projects[0] ?? null;
}, [activeProjectId, projects]);
}, [activeProjectId, isChatContext, projects]);
const projectRef = React.useMemo(() => {
if (isChatContext && chatsRoot) {
return { id: CHAT_DRAFT_PROJECT_ID, path: chatsRoot };
}
if (!activeProject) {
return null;
}
@@ -30,16 +45,17 @@ export const ProjectContextPanel: React.FC<{
id: activeProject.id,
path: activeProject.path,
};
}, [activeProject]);
}, [activeProject, chatsRoot, isChatContext]);
const projectLabel = React.useMemo(() => {
if (isChatContext) return t('sessions.sidebar.activity.chatsTitle');
if (!activeProject) {
return null;
}
return activeProject.label?.trim()
|| formatDirectoryName(activeProject.path, homeDirectory)
|| activeProject.path;
}, [activeProject, homeDirectory]);
}, [activeProject, homeDirectory, isChatContext, t]);
const canCreateWorktree = React.useMemo(() => {
if (!activeProject) {
@@ -20,6 +20,7 @@ import { Icon } from "@/components/icon/Icon";
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { contextTokensFromBreakdown } from '@/stores/utils/tokenUtils';
import type { SessionContextUsage } from '@/stores/types/sessionTypes';
import { isChatDirectoryPath } from '@/lib/chatDirectories';
type MiniChatMode = 'session' | 'draft';
@@ -51,6 +52,7 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => {
const { t } = useI18n();
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const draftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
const draftTarget = useSessionUIStore((state) => state.newSessionDraft.target);
const draftProjectId = useSessionUIStore((state) => state.newSessionDraft?.selectedProjectId ?? null);
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
const projects = useProjectsStore((state) => state.projects);
@@ -99,6 +101,7 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => {
const worktreeDirectory = normalizePath(worktreePath || sessionWorktreeMetadata?.path || worktreeAttachment?.cwd || worktreeAttachment?.worktreeRoot || '');
const currentDirectoryNormalized = normalizePath(currentDirectory);
const openDirectory = worktreeDirectory || sessionDirectory || draftDirectory || currentDirectoryNormalized;
const isChatContext = draftOpen ? draftTarget === 'chat' : isChatDirectoryPath(sessionDirectory);
const directoryLabel = compactPath(openDirectory);
const catalogWorktreeBranch = useSessionUIStore((state) => {
const candidateDirectory = normalizePath(worktreeDirectory || sessionDirectory || '');
@@ -111,9 +114,9 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => {
return null;
});
React.useEffect(() => {
if (!openDirectory) return;
if (!openDirectory || isChatContext) return;
void ensureGitStatus(openDirectory, runtimeApis.git).catch(() => {});
}, [ensureGitStatus, openDirectory, runtimeApis.git]);
}, [ensureGitStatus, isChatContext, openDirectory, runtimeApis.git]);
const pathMatchedProject = React.useMemo(() => {
const projectDirectory = normalizePath(sessionWorktreeMetadata?.projectDirectory ?? worktreeAttachment?.worktreeRoot ?? null);
@@ -125,13 +128,14 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => {
.sort((left, right) => right.path.length - left.path.length)[0] ?? null;
}, [openDirectory, projects, sessionWorktreeMetadata?.projectDirectory, worktreeAttachment?.worktreeRoot]);
const projectLabel = React.useMemo(() => {
if (isChatContext) return null;
const project = pathMatchedProject ?? activeProject;
if (!project) return directoryLabel || 'OpenChamber';
const label = project.label?.trim();
if (label) return label;
const segments = project.path.split(/[\\/]/).filter(Boolean);
return segments.at(-1) ?? project.path;
}, [activeProject, directoryLabel, pathMatchedProject]);
}, [activeProject, directoryLabel, isChatContext, pathMatchedProject]);
const gitBranchForDirectory = useGitBranchLabel(openDirectory || null);
const rawBranchLabel = gitBranchForDirectory || worktreeMetadataBranch || sessionWorktreeMetadata?.branch?.trim() || worktreeAttachment?.branch?.trim() || catalogWorktreeBranch;
const branchLabel = rawBranchLabel && rawBranchLabel !== 'HEAD' ? rawBranchLabel : null;
@@ -241,7 +245,11 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => {
const handleOpenMainApp = React.useCallback(() => {
const payload = currentSessionId
? { sessionId: currentSessionId, directory: (session as { directory?: string | null } | null)?.directory ?? currentDirectory ?? '' }
: { mode: 'draft', directory: openDirectory || currentDirectory || '', projectId: draftProjectId };
: {
mode: 'draft',
directory: isChatContext ? '' : openDirectory || currentDirectory || '',
projectId: isChatContext ? null : draftProjectId,
};
void invokeDesktop<{ focused?: boolean }>('desktop_focus_main_window', payload)
.then((result) => {
if (result?.focused === true) {
@@ -249,7 +257,7 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => {
}
return null;
});
}, [currentDirectory, currentSessionId, draftProjectId, openDirectory, session]);
}, [currentDirectory, currentSessionId, draftProjectId, isChatContext, openDirectory, session]);
return (
<header
@@ -273,7 +281,7 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => {
<span className="truncate typography-ui-label text-[14px] font-normal leading-tight text-foreground max-w-full">
{title}
</span>
<span className="flex min-w-0 max-w-full items-center gap-1.5 truncate typography-micro text-[10.5px] font-normal leading-tight text-muted-foreground/75">
{!isChatContext ? <span className="flex min-w-0 max-w-full items-center gap-1.5 truncate typography-micro text-[10.5px] font-normal leading-tight text-muted-foreground/75">
<span className="truncate">{projectLabel}</span>
{branchLabel ? (
<span className="inline-flex min-w-0 items-center gap-0.5">
@@ -281,7 +289,7 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => {
<span className="truncate">{branchLabel}</span>
</span>
) : null}
</span>
</span> : null}
</button>
</SessionSwitcherDropdown>
<div className="min-w-0 flex-1" />
@@ -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;
}