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